diff --git a/client/src/Main.tsx b/client/src/Main.tsx index 9e079471e43b94367438ab58c08d29a28a4ebd9b..5cd27cc3d83e96bda772e2e9b07282903ed069f5 100644 --- a/client/src/Main.tsx +++ b/client/src/Main.tsx @@ -1,5 +1,5 @@ import React, { useEffect } from 'react' -import { BrowserRouter, Switch } from 'react-router-dom' +import { BrowserRouter, Route, Switch } from 'react-router-dom' import { getTypes } from './actions/typesAction' import { useAppDispatch } from './hooks' import AdminPage from './pages/admin/AdminPage' @@ -7,8 +7,8 @@ import LoginPage from './pages/login/LoginPage' import PresentationEditorPage from './pages/presentationEditor/PresentationEditorPage' import AudienceViewPage from './pages/views/AudienceViewPage' import JudgeViewPage from './pages/views/JudgeViewPage' -import ParticipantViewPage from './pages/views/ParticipantViewPage' -import PresenterViewPage from './pages/views/PresenterViewPage' +import OperatorViewPage from './pages/views/OperatorViewPage' +import TeamViewPage from './pages/views/TeamViewPage' import ViewSelectPage from './pages/views/ViewSelectPage' import SecureRoute from './utils/SecureRoute' @@ -20,34 +20,34 @@ const Main: React.FC = () => { return ( <BrowserRouter> <Switch> - <SecureRoute authLevel={'login'} exact path="/" component={LoginPage} /> - <SecureRoute authLevel={'admin'} path="/admin" component={AdminPage} /> + <SecureRoute authLevel="login" exact path="/" component={LoginPage} /> + <SecureRoute authLevel="admin" path="/admin" component={AdminPage} /> <SecureRoute - authLevel={'admin'} + authLevel="admin" path="/editor/competition-id=:competitionId" component={PresentationEditorPage} /> - <SecureRoute authLevel={'competition'} exact path="/:code" component={ViewSelectPage} /> + <Route exact path="/:code" component={ViewSelectPage} /> <SecureRoute - authLevel={'competition'} + authLevel="competition" exact path="/team/competition-id=:competitionId" - component={ParticipantViewPage} + component={TeamViewPage} /> <SecureRoute - authLevel={'competition'} + authLevel="competition" exact path="/operator/competition-id=:competitionId" - component={PresenterViewPage} + component={OperatorViewPage} /> <SecureRoute - authLevel={'competition'} + authLevel="competition" exact path="/judge/competition-id=:competitionId" component={JudgeViewPage} /> <SecureRoute - authLevel={'competition'} + authLevel="competition" exact path="/audience/competition-id=:competitionId" component={AudienceViewPage} diff --git a/client/src/actions/competitionLogin.ts b/client/src/actions/competitionLogin.ts index ed75e71133ba9c28e83432c50b4fca04bd4637b2..448eec249f360acbadeb6161abac6412daccb32b 100644 --- a/client/src/actions/competitionLogin.ts +++ b/client/src/actions/competitionLogin.ts @@ -8,7 +8,9 @@ import { AppDispatch } from '../store' import Types from './types' // Action creator to attempt to login with competition code -export const loginCompetition = (code: string, history: History) => async (dispatch: AppDispatch) => { +export const loginCompetition = (code: string, history: History, redirect: boolean) => async ( + dispatch: AppDispatch +) => { dispatch({ type: Types.LOADING_COMPETITION_LOGIN }) await axios .post('/api/auth/login/code', { code }) @@ -16,10 +18,16 @@ export const loginCompetition = (code: string, history: History) => async (dispa const token = `Bearer ${res.data.access_token}` localStorage.setItem('competitionToken', token) //setting token to local storage axios.defaults.headers.common['Authorization'] = token //setting authorize token to header in axios - console.log(code, res.data) dispatch({ type: Types.CLEAR_COMPETITION_LOGIN_ERRORS }) // no error - // history.push('/admin') //redirecting to admin page after login success - if (res.data && res.data.view_type_id) { + dispatch({ + type: Types.SET_COMPETITION_LOGIN_DATA, + payload: { + competition_id: res.data.competition_id, + team_id: res.data.team_id, + view: res.data.view, + }, + }) + if (redirect && res.data && res.data.view_type_id) { history.push(`/${code}`) } }) @@ -31,7 +39,7 @@ export const loginCompetition = (code: string, history: History) => async (dispa // Log out from competition and remove jwt token from local storage and axios export const logoutCompetition = () => async (dispatch: AppDispatch) => { - localStorage.removeItem('token') + localStorage.removeItem('competitionToken') await axios.post('/api/auth/logout').then(() => { delete axios.defaults.headers.common['Authorization'] dispatch({ diff --git a/client/src/actions/editor.ts b/client/src/actions/editor.ts index db688dcb59f3732c6bda801b0aada1b3063a1238..526ad9ff6ae1698644e32ea02806c7a6e8e2cd78 100644 --- a/client/src/actions/editor.ts +++ b/client/src/actions/editor.ts @@ -18,16 +18,28 @@ export const getEditorCompetition = (id: string) => async (dispatch: AppDispatch if (getState().editor.activeSlideId === -1 && res.data.slides[0]) { setEditorSlideId(res.data.slides[0].id)(dispatch) } + const defaultViewType = getState().types.viewTypes.find((viewType) => viewType.name === 'Audience') + if (getState().editor.activeViewTypeId === -1 && defaultViewType) { + setEditorViewId(defaultViewType.id)(dispatch) + } }) .catch((err) => { console.log(err) }) } -// Set currentSlideId in editor state +// Set activeSlideId in editor state export const setEditorSlideId = (id: number) => (dispatch: AppDispatch) => { dispatch({ type: Types.SET_EDITOR_SLIDE_ID, payload: id, }) } + +// Set activeViewTypeId in editor state +export const setEditorViewId = (id: number) => (dispatch: AppDispatch) => { + dispatch({ + type: Types.SET_EDITOR_VIEW_ID, + payload: id, + }) +} diff --git a/client/src/actions/types.ts b/client/src/actions/types.ts index 815e9d48c71a4e87409b79d635f198f3b4c8333d..b422f95423539cc2f2ea0bf4e7ecdc58f78bdbc8 100644 --- a/client/src/actions/types.ts +++ b/client/src/actions/types.ts @@ -14,6 +14,7 @@ export default { SET_SEARCH_USERS_TOTAL_COUNT: 'SET_SEARCH_USERS_TOTAL_COUNT', SET_ERRORS: 'SET_ERRORS', CLEAR_ERRORS: 'CLEAR_ERRORS', + SET_COMPETITION_LOGIN_DATA: 'SET_COMPETITION_LOGIN_DATA', SET_COMPETITION_LOGIN_AUTHENTICATED: 'SET_COMPETITION_LOGIN_AUTHENTICATED', SET_COMPETITION_LOGIN_UNAUTHENTICATED: 'SET_COMPETITION_LOGIN_UNAUTHENTICATED', SET_COMPETITION_LOGIN_ERRORS: 'SET_COMPETITION_LOGIN_ERRORS', @@ -26,6 +27,7 @@ export default { SET_COMPETITIONS_COUNT: 'SET_COMPETITIONS_COUNT', SET_EDITOR_COMPETITION: 'SET_EDITOR_COMPETITION', SET_EDITOR_SLIDE_ID: 'SET_EDITOR_SLIDE_ID', + SET_EDITOR_VIEW_ID: 'SET_EDITOR_VIEW_ID', SET_PRESENTATION_COMPETITION: 'SET_PRESENTATION_COMPETITION', SET_PRESENTATION_SLIDE: 'SET_PRESENTATION_SLIDE', SET_PRESENTATION_SLIDE_PREVIOUS: 'SET_PRESENTATION_SLIDE_PREVIOUS', diff --git a/client/src/interfaces/ApiModels.ts b/client/src/interfaces/ApiModels.ts index 06fdee0afa3b467c5d0fe21f525b9ca7ec5e6a6a..a6fa68a0e9b58dcc63ad67d29884bbf3e781a64a 100644 --- a/client/src/interfaces/ApiModels.ts +++ b/client/src/interfaces/ApiModels.ts @@ -80,6 +80,8 @@ export interface Component { w: number h: number type_id: number + view_type_id: number + slide_id: number } export interface ImageComponent extends Component { diff --git a/client/src/interfaces/ViewParams.ts b/client/src/interfaces/ViewParams.ts index e9aa6a5c5f81a6bf852f8caa30443a793b0dddd7..b8114216500b3295050a0b62f6188fa065fcbfdb 100644 --- a/client/src/interfaces/ViewParams.ts +++ b/client/src/interfaces/ViewParams.ts @@ -1,4 +1,3 @@ export interface ViewParams { - id: string - code: string + competitionId: string } diff --git a/client/src/pages/admin/competitions/CompetitionManager.tsx b/client/src/pages/admin/competitions/CompetitionManager.tsx index 83f399b95a0c083727333c2f842a1c61a8eca2d0..8ec3ee2981277a3f150642410581d472602e65ed 100644 --- a/client/src/pages/admin/competitions/CompetitionManager.tsx +++ b/client/src/pages/admin/competitions/CompetitionManager.tsx @@ -94,7 +94,7 @@ const CompetitionManager: React.FC = (props: any) => { } const handleStartCompetition = () => { - history.push(`/presenter/id=${activeId}&code=123123`) + history.push(`/operator/id=${activeId}&code=123123`) console.log('GLHF!') } diff --git a/client/src/pages/login/components/CompetitionLogin.tsx b/client/src/pages/login/components/CompetitionLogin.tsx index d89cafcf8216197de081d65550116e2e06b56f22..8dbbee33b6962c5bcd21f346ee594b224719a9a7 100644 --- a/client/src/pages/login/components/CompetitionLogin.tsx +++ b/client/src/pages/login/components/CompetitionLogin.tsx @@ -1,9 +1,8 @@ import { Button, TextField, Typography } from '@material-ui/core' import { Alert, AlertTitle } from '@material-ui/lab' -import axios from 'axios' -import { Formik, FormikHelpers } from 'formik' +import { Formik } from 'formik' +import React from 'react' import { useHistory } from 'react-router-dom' -import React, { useEffect, useState } from 'react' import * as Yup from 'yup' import { loginCompetition } from '../../../actions/competitionLogin' import { useAppDispatch, useAppSelector } from '../../../hooks' @@ -37,8 +36,9 @@ const CompetitionLogin: React.FC = () => { model: { code: '' }, } const handleCompetitionSubmit = async (values: CompetitionLoginFormModel) => { - dispatch(loginCompetition(values.model.code, history)) + dispatch(loginCompetition(values.model.code, history, true)) } + return ( <Formik initialValues={competitionInitialValues} diff --git a/client/src/pages/presentationEditor/PresentationEditorPage.test.tsx b/client/src/pages/presentationEditor/PresentationEditorPage.test.tsx index c645724e3c7fabda69a2ad2434f4bd7b587c8c2e..5426152ff33783745f8dc1181237cd2926b0a16a 100644 --- a/client/src/pages/presentationEditor/PresentationEditorPage.test.tsx +++ b/client/src/pages/presentationEditor/PresentationEditorPage.test.tsx @@ -27,8 +27,19 @@ it('renders presentation editor', () => { ], }, } + const typesRes: any = { + data: { + view_types: [ + { + name: '', + id: 0, + }, + ], + }, + } ;(mockedAxios.get as jest.Mock).mockImplementation((path: string, params?: any) => { if (path.startsWith('/api/competitions')) return Promise.resolve(competitionRes) + if (path.startsWith('/api/misc/types')) return Promise.resolve(typesRes) return Promise.resolve(citiesRes) }) render( diff --git a/client/src/pages/presentationEditor/PresentationEditorPage.tsx b/client/src/pages/presentationEditor/PresentationEditorPage.tsx index cd9d22c2055af4b4ddded3260db8b124b9124e6d..80d35c5f38c8b879976b91204f9572ac9cf5735a 100644 --- a/client/src/pages/presentationEditor/PresentationEditorPage.tsx +++ b/client/src/pages/presentationEditor/PresentationEditorPage.tsx @@ -10,7 +10,7 @@ import axios from 'axios' import React, { useEffect, useState } from 'react' import { Link, useParams } from 'react-router-dom' import { getCities } from '../../actions/cities' -import { getEditorCompetition, setEditorSlideId } from '../../actions/editor' +import { getEditorCompetition, setEditorSlideId, setEditorViewId } from '../../actions/editor' import { getTypes } from '../../actions/typesAction' import { useAppDispatch, useAppSelector } from '../../hooks' import { RichSlide } from '../../interfaces/ApiRichModels' @@ -27,6 +27,7 @@ import { SlideListItem, ToolBarContainer, ViewButton, + ViewButtonClicked, ViewButtonGroup, } from './styled' @@ -88,12 +89,13 @@ const PresentationEditorPage: React.FC = () => { const { competitionId }: CompetitionParams = useParams() const dispatch = useAppDispatch() const activeSlideId = useAppSelector((state) => state.editor.activeSlideId) + const activeViewTypeId = useAppSelector((state) => state.editor.activeViewTypeId) const competition = useAppSelector((state) => state.editor.competition) const competitionLoading = useAppSelector((state) => state.editor.loading) useEffect(() => { + dispatch(getTypes()) dispatch(getEditorCompetition(competitionId)) dispatch(getCities()) - dispatch(getTypes()) }, []) const setActiveSlideId = (id: number) => { @@ -147,6 +149,16 @@ const PresentationEditorPage: React.FC = () => { })((props: CheckboxProps) => <Checkbox color="default" {...props} />) const [checkbox, setCheckbox] = useState(false) + const viewTypes = useAppSelector((state) => state.types.viewTypes) + const [activeViewTypeName, setActiveViewTypeName] = useState('') + const changeView = (clickedViewTypeName: string) => { + setActiveViewTypeName(clickedViewTypeName) + const clickedViewTypeId = viewTypes.find((viewType) => viewType.name === clickedViewTypeName)?.id + if (clickedViewTypeId) { + dispatch(setEditorViewId(clickedViewTypeId)) + } + } + return ( <PresentationEditorContainer> <CssBaseline /> @@ -164,10 +176,20 @@ const PresentationEditorPage: React.FC = () => { <Typography className={classes.alignCheckboxText} variant="button"> Applicera ändringar på samtliga vyer </Typography> - <ViewButton variant="contained" color="secondary"> + <ViewButton + $activeView={activeViewTypeName === 'Audience'} + variant="contained" + color="secondary" + onClick={() => changeView('Audience')} + > Åskådarvy </ViewButton> - <ViewButton variant="contained" color="secondary"> + <ViewButton + $activeView={activeViewTypeName === 'Team'} + variant="contained" + color="secondary" + onClick={() => changeView('Team')} + > Deltagarvy </ViewButton> </ViewButtonGroup> @@ -229,7 +251,7 @@ const PresentationEditorPage: React.FC = () => { <Content leftDrawerWidth={leftDrawerWidth} rightDrawerWidth={rightDrawerWidth}> <InnerContent> - <SlideDisplay editor /> + <SlideDisplay variant="editor" activeViewTypeId={activeViewTypeId} /> </InnerContent> </Content> <Menu diff --git a/client/src/pages/presentationEditor/components/BackgroundImageSelect.tsx b/client/src/pages/presentationEditor/components/BackgroundImageSelect.tsx new file mode 100644 index 0000000000000000000000000000000000000000..14dbaa63ed2cc267a080264ebe3d7abd243220ba --- /dev/null +++ b/client/src/pages/presentationEditor/components/BackgroundImageSelect.tsx @@ -0,0 +1,126 @@ +import { ListItem, ListItemText, Typography } from '@material-ui/core' +import React, { useState } from 'react' +import { useAppDispatch, useAppSelector } from '../../../hooks' +import { + AddButton, + AddBackgroundButton, + Center, + HiddenInput, + ImportedImage, + SettingsList, + ImageNameText, + ImageTextContainer, +} from './styled' +import CloseIcon from '@material-ui/icons/Close' +import axios from 'axios' +import { Media } from '../../../interfaces/ApiModels' +import { getEditorCompetition } from '../../../actions/editor' +import { uploadFile } from '../../../utils/uploadImage' + +type BackgroundImageSelectProps = { + variant: 'competition' | 'slide' +} + +const BackgroundImageSelect = ({ variant }: BackgroundImageSelectProps) => { + const activeSlideId = useAppSelector((state) => state.editor.activeSlideId) + const backgroundImage = useAppSelector((state) => { + if (variant === 'competition') return state.editor.competition.background_image + else return state.editor.competition.slides.find((slide) => slide.id === activeSlideId)?.background_image + }) + const competitionId = useAppSelector((state) => state.editor.competition.id) + const dispatch = useAppDispatch() + + const updateBackgroundImage = async (mediaId: number) => { + // Creates a new image component on the database using API call. + if (variant === 'competition') { + await axios + .put(`/api/competitions/${competitionId}`, { background_image_id: mediaId }) + .then(() => { + dispatch(getEditorCompetition(competitionId.toString())) + }) + .catch(console.log) + } else { + await axios + .put(`/api/competitions/${competitionId}/slides/${activeSlideId}`, { background_image_id: mediaId }) + .then(() => { + dispatch(getEditorCompetition(competitionId.toString())) + }) + .catch(console.log) + } + } + + const removeBackgroundImage = async () => { + // Removes background image media and from competition using API calls. + await axios.delete(`/api/media/images/${backgroundImage?.id}`).catch(console.log) + if (variant === 'competition') { + await axios + .put(`/api/competitions/${competitionId}`, { background_image_id: null }) + .then(() => { + dispatch(getEditorCompetition(competitionId.toString())) + }) + .catch(console.log) + } else { + await axios + .put(`/api/competitions/${competitionId}/slides/${activeSlideId}`, { background_image_id: null }) + .then(() => { + dispatch(getEditorCompetition(competitionId.toString())) + }) + .catch(console.log) + } + } + + const handleFileSelected = async (e: React.ChangeEvent<HTMLInputElement>) => { + // Reads the selected image file and uploads it to the server. + // Creates a new image component containing the file. + if (e.target.files !== null && e.target.files[0]) { + const files = Array.from(e.target.files) + const file = files[0] + const formData = new FormData() + formData.append('image', file) + const media = await uploadFile(formData, competitionId.toString()) + if (media) { + updateBackgroundImage(media.id) + } + } + } + + return ( + <SettingsList> + {!backgroundImage && ( + <ListItem button style={{ padding: 0 }}> + <HiddenInput + accept="image/*" + id="background-button-file" + multiple + type="file" + onChange={handleFileSelected} + /> + <AddBackgroundButton htmlFor="background-button-file"> + <Center> + <AddButton variant="button">Välj bakgrundsbild...</AddButton> + </Center> + </AddBackgroundButton> + </ListItem> + )} + {backgroundImage && ( + <> + <ListItem divider> + <ImageTextContainer> + <ListItemText>Bakgrundsbild</ListItemText> + <Typography variant="body2">(Bilden bör ha sidförhållande 16:9)</Typography> + </ImageTextContainer> + </ListItem> + <ListItem divider button> + <ImportedImage src={`/static/images/thumbnail_${backgroundImage.filename}`} /> + <Center> + <ImageNameText primary={backgroundImage.filename} /> + </Center> + <CloseIcon onClick={removeBackgroundImage} /> + </ListItem> + </> + )} + </SettingsList> + ) +} + +export default BackgroundImageSelect diff --git a/client/src/pages/presentationEditor/components/CompetitionSettings.tsx b/client/src/pages/presentationEditor/components/CompetitionSettings.tsx index 7ea2a84f99171fd75555e606abe2681a7f3c0c0d..9482b660550e729f2a5a4c443d0f935193679b1f 100644 --- a/client/src/pages/presentationEditor/components/CompetitionSettings.tsx +++ b/client/src/pages/presentationEditor/components/CompetitionSettings.tsx @@ -17,6 +17,7 @@ import { getEditorCompetition } from '../../../actions/editor' import { useAppDispatch, useAppSelector } from '../../../hooks' import { City } from '../../../interfaces/ApiModels' import Teams from './Teams' +import BackgroundImageSelect from './BackgroundImageSelect' interface CompetitionParams { competitionId: string @@ -92,17 +93,7 @@ const CompetitionSettings: React.FC = () => { <Teams competitionId={competitionId} /> - <SettingsList> - <ListItem button> - <ImportedImage - id="temp source, todo: add image source to elements of pictureList" - src="https://i1.wp.com/stickoutmedia.se/wp-content/uploads/2021/01/placeholder-3.png?ssl=1" - /> - <Center> - <ListItemText>Välj bakgrundsbild ...</ListItemText> - </Center> - </ListItem> - </SettingsList> + <BackgroundImageSelect variant="competition" /> </PanelContainer> ) } diff --git a/client/src/pages/presentationEditor/components/RndComponent.tsx b/client/src/pages/presentationEditor/components/RndComponent.tsx index e1cbc0f21652fe4eb65ed45fc65896c0c9f1bbab..8f2324c93a36d8e246043a4b5adeaec990f88dae 100644 --- a/client/src/pages/presentationEditor/components/RndComponent.tsx +++ b/client/src/pages/presentationEditor/components/RndComponent.tsx @@ -1,15 +1,13 @@ -import { Button, Card, IconButton, Tooltip, Typography } from '@material-ui/core' +import { Card, IconButton, Tooltip } from '@material-ui/core' import axios from 'axios' import React, { useEffect, useState } from 'react' import { Rnd } from 'react-rnd' import { ComponentTypes } from '../../../enum/ComponentTypes' import { useAppSelector } from '../../../hooks' -import { Component, ImageComponent, QuestionAlternativeComponent, TextComponent } from '../../../interfaces/ApiModels' +import { Component, ImageComponent, TextComponent } from '../../../interfaces/ApiModels' import { Position, Size } from '../../../interfaces/Components' -import CheckboxComponent from './CheckboxComponent' import ImageComponentDisplay from './ImageComponentDisplay' import { HoverContainer } from './styled' -import FormatAlignCenterIcon from '@material-ui/icons/FormatAlignCenter' import TextComponentDisplay from './TextComponentDisplay' type RndComponentProps = { @@ -26,6 +24,9 @@ const RndComponent = ({ component, width, height, scale }: RndComponentProps) => const competitionId = useAppSelector((state) => state.editor.competition.id) const slideId = useAppSelector((state) => state.editor.activeSlideId) const [shiftPressed, setShiftPressed] = useState(false) + const typeName = useAppSelector( + (state) => state.types.componentTypes.find((componentType) => componentType.id === component.type_id)?.name + ) const handleUpdatePos = (pos: Position) => { axios.put(`/api/competitions/${competitionId}/slides/${slideId}/components/${component.id}`, { x: pos.x, @@ -39,13 +40,11 @@ const RndComponent = ({ component, width, height, scale }: RndComponentProps) => }) } const handleCenterHorizontal = () => { - console.log(width, currentSize.w) const centerX = width / (2 * scale) - currentSize.w / 2 setCurrentPos({ x: centerX, y: currentPos.y }) handleUpdatePos({ x: centerX, y: currentPos.y }) } const handleCenterVertical = () => { - console.log(height, currentSize.h) const centerY = height / (2 * scale) - currentSize.h / 2 setCurrentPos({ x: currentPos.x, y: centerY }) handleUpdatePos({ x: currentPos.x, y: centerY }) @@ -98,6 +97,8 @@ const RndComponent = ({ component, width, height, scale }: RndComponentProps) => setCurrentPos({ x: d.x / scale, y: d.y / scale }) handleUpdatePos({ x: d.x / scale, y: d.y / scale }) }} + //Makes text appear on images + style={{ zIndex: typeName === 'Text' ? 2 : 1 }} lockAspectRatio={shiftPressed} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} @@ -114,6 +115,7 @@ const RndComponent = ({ component, width, height, scale }: RndComponentProps) => w: ref.offsetWidth / scale, h: ref.offsetHeight / scale, }) + setCurrentPos({ x: position.x / scale, y: position.y / scale }) }} > {hover && ( diff --git a/client/src/pages/presentationEditor/components/SlideDisplay.tsx b/client/src/pages/presentationEditor/components/SlideDisplay.tsx index f134d0a8ff4e8fef092ae4b92ef8e6b07fa38a71..aef4ca7c48d29bbfc47cac8f29b214ec6866f360 100644 --- a/client/src/pages/presentationEditor/components/SlideDisplay.tsx +++ b/client/src/pages/presentationEditor/components/SlideDisplay.tsx @@ -1,4 +1,3 @@ -import { Button, Typography } from '@material-ui/core' import React, { useEffect, useLayoutEffect, useRef, useState } from 'react' import { getTypes } from '../../../actions/typesAction' import { useAppDispatch, useAppSelector } from '../../../hooks' @@ -8,15 +7,27 @@ import { SlideEditorContainer, SlideEditorContainerRatio, SlideEditorPaper } fro type SlideDisplayProps = { //Prop to distinguish between editor and active competition - editor?: boolean | undefined + variant: 'editor' | 'presentation' + activeViewTypeId: number } -const SlideDisplay = ({ editor }: SlideDisplayProps) => { +const SlideDisplay = ({ variant, activeViewTypeId }: SlideDisplayProps) => { const components = useAppSelector((state) => { - if (editor) + if (variant === 'editor') return state.editor.competition.slides.find((slide) => slide.id === state.editor.activeSlideId)?.components return state.presentation.competition.slides.find((slide) => slide.id === state.presentation.slide?.id)?.components }) + const competitionBackgroundImage = useAppSelector((state) => { + if (variant === 'editor') return state.editor.competition.background_image + return state.presentation.competition.background_image + }) + + const slideBackgroundImage = useAppSelector((state) => { + if (variant === 'editor') + return state.editor.competition.slides.find((slide) => slide.id === state.editor.activeSlideId)?.background_image + return state.presentation.competition.slides.find((slide) => slide.id === state.presentation.slide.id) + ?.background_image + }) const dispatch = useAppDispatch() const editorPaperRef = useRef<HTMLDivElement>(null) const [width, setWidth] = useState(0) @@ -42,22 +53,40 @@ const SlideDisplay = ({ editor }: SlideDisplayProps) => { <SlideEditorContainer> <SlideEditorContainerRatio> <SlideEditorPaper ref={editorPaperRef}> + {(competitionBackgroundImage || slideBackgroundImage) && ( + <img + src={`/static/images/${ + slideBackgroundImage ? slideBackgroundImage.filename : competitionBackgroundImage?.filename + }`} + height={height} + width={width} + draggable={false} + /> + )} {components && - components.map((component) => { - if (editor) + components + .filter((component) => component.view_type_id === activeViewTypeId) + .map((component) => { + if (variant === 'editor') + return ( + <RndComponent + height={height} + width={width} + key={component.id} + component={component} + scale={scale} + /> + ) return ( - <RndComponent height={height} width={width} key={component.id} component={component} scale={scale} /> + <PresentationComponent + height={height} + width={width} + key={component.id} + component={component} + scale={scale} + /> ) - return ( - <PresentationComponent - height={height} - width={width} - key={component.id} - component={component} - scale={scale} - /> - ) - })} + })} </SlideEditorPaper> </SlideEditorContainerRatio> </SlideEditorContainer> diff --git a/client/src/pages/presentationEditor/components/SlideSettings.tsx b/client/src/pages/presentationEditor/components/SlideSettings.tsx index 278887c1cb8b841224704ceb1f2e1bb9fa5c7b6a..d48b4565712d043d52d3b1e93e3633ec6b927d38 100644 --- a/client/src/pages/presentationEditor/components/SlideSettings.tsx +++ b/client/src/pages/presentationEditor/components/SlideSettings.tsx @@ -12,6 +12,7 @@ import Timer from './slideSettingsComponents/Timer' import Images from './slideSettingsComponents/Images' import Texts from './slideSettingsComponents/Texts' import QuestionSettings from './slideSettingsComponents/QuestionSettings' +import BackgroundImageSelect from './BackgroundImageSelect' interface CompetitionParams { competitionId: string @@ -24,6 +25,7 @@ const SlideSettings: React.FC = () => { // Gets the slide with id=activeSlideId from the database. state.editor.competition.slides.find((slide) => slide && slide.id === state.editor.activeSlideId) ) + const activeViewTypeId = useAppSelector((state) => state.editor.activeViewTypeId) return ( <PanelContainer> @@ -47,21 +49,15 @@ const SlideSettings: React.FC = () => { <MultipleChoiceAlternatives activeSlide={activeSlide} competitionId={competitionId} /> )} - {activeSlide && <Texts activeSlide={activeSlide} competitionId={competitionId} />} + {activeSlide && ( + <Texts activeViewTypeId={activeViewTypeId} activeSlide={activeSlide} competitionId={competitionId} /> + )} - {activeSlide && <Images activeSlide={activeSlide} competitionId={competitionId} />} + {activeSlide && ( + <Images activeViewTypeId={activeViewTypeId} activeSlide={activeSlide} competitionId={competitionId} /> + )} - <SettingsList> - <ListItem button> - <ImportedImage - id="temp source, todo: add image source to elements of pictureList" - src="https://i1.wp.com/stickoutmedia.se/wp-content/uploads/2021/01/placeholder-3.png?ssl=1" - /> - <Center> - <ListItemText>Välj bakgrundsbild ...</ListItemText> - </Center> - </ListItem> - </SettingsList> + <BackgroundImageSelect variant="slide" /> </PanelContainer> ) } diff --git a/client/src/pages/presentationEditor/components/TextComponentEdit.tsx b/client/src/pages/presentationEditor/components/TextComponentEdit.tsx index b03696f37987a83347f57fb81c6b25febcb84824..04ddd6daa7bb015b3e48b905b3f17f3594c8ba98 100644 --- a/client/src/pages/presentationEditor/components/TextComponentEdit.tsx +++ b/client/src/pages/presentationEditor/components/TextComponentEdit.tsx @@ -20,6 +20,7 @@ const TextComponentEdit = ({ component }: ImageComponentProps) => { const [content, setContent] = useState('') const [timerHandle, setTimerHandle] = React.useState<number | undefined>(undefined) const activeSlideId = useAppSelector((state) => state.editor.activeSlideId) + const activeViewTypeId = useAppSelector((state) => state.editor.activeViewTypeId) const dispatch = useAppDispatch() useEffect(() => { @@ -35,7 +36,6 @@ const TextComponentEdit = ({ component }: ImageComponentProps) => { //Only updates 250ms after last input was made to not spam setTimerHandle( window.setTimeout(async () => { - console.log('Content was updated on server. id: ', component.id) await axios.put(`/api/competitions/${competitionId}/slides/${activeSlideId}/components/${component.id}`, { text: newText, }) diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/Images.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/Images.tsx index fa087dccbb39c77a0a2e433d5a6a6c8b4d201fd1..49f41e7f87fa6c53dac59704540ca3c186ef708b 100644 --- a/client/src/pages/presentationEditor/components/slideSettingsComponents/Images.tsx +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/Images.tsx @@ -11,11 +11,12 @@ import { ImageComponent, Media } from '../../../../interfaces/ApiModels' import { useAppDispatch, useAppSelector } from '../../../../hooks' type ImagesProps = { + activeViewTypeId: number activeSlide: RichSlide competitionId: string } -const Images = ({ activeSlide, competitionId }: ImagesProps) => { +const Images = ({ activeViewTypeId, activeSlide, competitionId }: ImagesProps) => { const dispatch = useAppDispatch() const uploadFile = async (formData: FormData) => { @@ -37,6 +38,7 @@ const Images = ({ activeSlide, competitionId }: ImagesProps) => { y: 0, media_id: media.id, type_id: 2, + view_type_id: activeViewTypeId, } await axios .post(`/api/competitions/${competitionId}/slides/${activeSlide?.id}/components`, imageData) @@ -56,7 +58,7 @@ const Images = ({ activeSlide, competitionId }: ImagesProps) => { formData.append('image', file) const response = await uploadFile(formData) if (response) { - const newComponent = createImageComponent(response) + createImageComponent(response) } } } @@ -94,17 +96,19 @@ const Images = ({ activeSlide, competitionId }: ImagesProps) => { </Center> </ListItem> {images && - images.map((image) => ( - <div key={image.id}> - <ListItem divider button> - <ImportedImage src={`http://localhost:5000/static/images/thumbnail_${image.media?.filename}`} /> - <Center> - <ListItemText primary={image.media?.filename} /> - </Center> - <CloseIcon onClick={() => handleCloseimageClick(image)} /> - </ListItem> - </div> - ))} + images + .filter((image) => image.view_type_id === activeViewTypeId) + .map((image) => ( + <div key={image.id}> + <ListItem divider button> + <ImportedImage src={`http://localhost:5000/static/images/thumbnail_${image.media?.filename}`} /> + <Center> + <ListItemText primary={image.media?.filename} /> + </Center> + <CloseIcon onClick={() => handleCloseimageClick(image)} /> + </ListItem> + </div> + ))} <ListItem button style={{ padding: 0 }}> <HiddenInput accept="image/*" id="contained-button-file" multiple type="file" onChange={handleFileSelected} /> diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/Instructions.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/Instructions.tsx index 858dd75e65dc554cc6e1c2083f46873a925335a1..7d609300966a6f6e5141685f1fbaed7e0298d7ba 100644 --- a/client/src/pages/presentationEditor/components/slideSettingsComponents/Instructions.tsx +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/Instructions.tsx @@ -1,4 +1,4 @@ -import { ListItem, ListItemText, TextField, withStyles } from '@material-ui/core' +import { ListItem, ListItemText, TextField } from '@material-ui/core' import axios from 'axios' import React from 'react' import { getEditorCompetition } from '../../../../actions/editor' @@ -23,7 +23,6 @@ const Instructions = ({ activeSlide, competitionId }: InstructionsProps) => { //Only updates 250ms after last input was made to not spam setTimerHandle( window.setTimeout(async () => { - console.log('Content was updated on server. id: ', activeSlide.questions[0].id) if (activeSlide && activeSlide.questions[0]) { await axios // TODO: Implement instructions field in question and add put API diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/QuestionSettings.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/QuestionSettings.tsx index 2bc10b3588133245d54767dbbc7d8381e4134b59..e917afbd19f1aac2d7256c1f5913d8a63f323ee1 100644 --- a/client/src/pages/presentationEditor/components/slideSettingsComponents/QuestionSettings.tsx +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/QuestionSettings.tsx @@ -18,7 +18,6 @@ const QuestionSettings = ({ activeSlide, competitionId }: QuestionSettingsProps) updateTitle: boolean, event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement> ) => { - console.log('Content was updated on server. id: ', activeSlide.questions[0].id) if (activeSlide && activeSlide.questions[0]) { if (updateTitle) { await axios diff --git a/client/src/pages/presentationEditor/components/slideSettingsComponents/Texts.tsx b/client/src/pages/presentationEditor/components/slideSettingsComponents/Texts.tsx index 39cf09af3ca31b73f30c258570e28365117a4282..03656614e3076e6048984e70b7ddd0ae711a492d 100644 --- a/client/src/pages/presentationEditor/components/slideSettingsComponents/Texts.tsx +++ b/client/src/pages/presentationEditor/components/slideSettingsComponents/Texts.tsx @@ -9,11 +9,12 @@ import axios from 'axios' import { getEditorCompetition } from '../../../../actions/editor' type TextsProps = { + activeViewTypeId: number activeSlide: RichSlide competitionId: string } -const Texts = ({ activeSlide, competitionId }: TextsProps) => { +const Texts = ({ activeViewTypeId, activeSlide, competitionId }: TextsProps) => { const texts = useAppSelector( (state) => state.editor.competition.slides @@ -29,6 +30,7 @@ const Texts = ({ activeSlide, competitionId }: TextsProps) => { text: 'Ny text', w: 315, h: 50, + view_type_id: activeViewTypeId, }) dispatch(getEditorCompetition(competitionId)) } @@ -42,12 +44,14 @@ const Texts = ({ activeSlide, competitionId }: TextsProps) => { </Center> </ListItem> {texts && - texts.map((text) => ( - <TextCard elevation={4} key={text.id}> - <TextComponentEdit component={text} /> - <Divider /> - </TextCard> - ))} + texts + .filter((text) => text.view_type_id === activeViewTypeId) + .map((text) => ( + <TextCard elevation={4} key={text.id}> + <TextComponentEdit component={text} /> + <Divider /> + </TextCard> + ))} <ListItem button onClick={handleAddText}> <Center> <AddButton variant="button">Lägg till text</AddButton> diff --git a/client/src/pages/presentationEditor/components/styled.tsx b/client/src/pages/presentationEditor/components/styled.tsx index 7255c2bbc8a57475662191cd57428986a105348b..605972d2c4ee798388f9437f373cbabeab777d3c 100644 --- a/client/src/pages/presentationEditor/components/styled.tsx +++ b/client/src/pages/presentationEditor/components/styled.tsx @@ -9,6 +9,7 @@ import { ListItem, Select, InputLabel, + ListItemText, } from '@material-ui/core' import styled from 'styled-components' @@ -71,6 +72,14 @@ export const Center = styled.div` width: 100%; ` +export const ImageTextContainer = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; +` + export const PanelContainer = styled.div` padding: 10px; width: 100%; @@ -91,7 +100,16 @@ export const Clickable = styled.div` export const AddImageButton = styled.label` padding: 8px 13px 8px 13px; - cursor: 'pointer'; + display: flex; + justify-content: center; + text-align: center; + height: 100%; + width: 100%; + cursor: pointer; +` + +export const AddBackgroundButton = styled.label` + padding: 16px 29px 16px 29px; display: flex; justify-content: center; text-align: center; @@ -126,3 +144,7 @@ export const HoverContainer = styled.div<HoverContainerProps>` padding: ${(props) => (props.hover ? 0 : 1)}px; border: solid ${(props) => (props.hover ? 1 : 0)}px; ` + +export const ImageNameText = styled(ListItemText)` + word-break: break-all; +` diff --git a/client/src/pages/presentationEditor/styled.tsx b/client/src/pages/presentationEditor/styled.tsx index d1f05d6bf18de2eccc6b486fb6e94701acce3b5c..f68eb166043c970cb5f6e69bc57b090790b1d607 100644 --- a/client/src/pages/presentationEditor/styled.tsx +++ b/client/src/pages/presentationEditor/styled.tsx @@ -7,8 +7,18 @@ export const ToolBarContainer = styled(Toolbar)` padding-left: 0; ` -export const ViewButton = styled(Button)` +interface ViewButtonProps { + $activeView: boolean +} + +export const ViewButton = styled(Button)<ViewButtonProps>` + margin-right: 8px; + background: ${(props) => (props.$activeView ? '#5a0017' : undefined)}; +` + +export const ViewButtonClicked = styled(Button)` margin-right: 8px; + background: #5a0017; ` export const ViewButtonGroup = styled.div` diff --git a/client/src/pages/views/AudienceViewPage.tsx b/client/src/pages/views/AudienceViewPage.tsx index 8d58a364e6d4688da1b0dcff5290a60486250de1..48b92c4686c762536c629e1c33f5bb33ade9cc59 100644 --- a/client/src/pages/views/AudienceViewPage.tsx +++ b/client/src/pages/views/AudienceViewPage.tsx @@ -1,16 +1,36 @@ -import React from 'react' +import { Typography } from '@material-ui/core' +import React, { useEffect } from 'react' +import { useParams } from 'react-router-dom' +import { getPresentationCompetition } from '../../actions/presentation' +import { useAppDispatch, useAppSelector } from '../../hooks' +import { ViewParams } from '../../interfaces/ViewParams' +import { socketConnect, socketJoinPresentation } from '../../sockets' import SlideDisplay from '../presentationEditor/components/SlideDisplay' -import PresentationComponent from './components/PresentationComponent' -import mockedAxios from 'axios' +import { PresentationBackground, PresentationContainer } from './styled' const AudienceViewPage: React.FC = () => { - const res = { - data: {}, + const { competitionId }: ViewParams = useParams() + const code = useAppSelector((state) => state.presentation.code) + const dispatch = useAppDispatch() + const viewTypes = useAppSelector((state) => state.types.viewTypes) + const activeViewTypeId = viewTypes.find((viewType) => viewType.name === 'Audience')?.id + useEffect(() => { + dispatch(getPresentationCompetition(competitionId)) + if (code && code !== '') { + socketConnect() + socketJoinPresentation() + } + }, []) + if (activeViewTypeId) { + return ( + <PresentationBackground> + <PresentationContainer> + <SlideDisplay variant="presentation" activeViewTypeId={activeViewTypeId} /> + </PresentationContainer> + </PresentationBackground> + ) } - ;(mockedAxios.get as jest.Mock).mockImplementation(() => { - return Promise.resolve(res) - }) - return <SlideDisplay /> + return <Typography>Error: Åskådarvyn kunde inte laddas</Typography> } export default AudienceViewPage diff --git a/client/src/pages/views/JudgeViewPage.tsx b/client/src/pages/views/JudgeViewPage.tsx index 5a806d237f190d6483bc8265ec0eac61c58e9151..c6e1a79dca481c397d5d5801e6b3c5be9c6ef93d 100644 --- a/client/src/pages/views/JudgeViewPage.tsx +++ b/client/src/pages/views/JudgeViewPage.tsx @@ -1,14 +1,16 @@ -import { Card, Divider, List, ListItem, ListItemText, Paper, Typography } from '@material-ui/core' +import { Divider, List, ListItemText, Typography } from '@material-ui/core' import { createStyles, makeStyles, Theme } from '@material-ui/core/styles' import React, { useEffect, useState } from 'react' -import { getPresentationCompetition, setCurrentSlide, setPresentationCode } from '../../actions/presentation' +import { useHistory, useParams } from 'react-router-dom' +import { getPresentationCompetition, setCurrentSlide } from '../../actions/presentation' import { useAppDispatch, useAppSelector } from '../../hooks' import { ViewParams } from '../../interfaces/ViewParams' -import { socket_connect } from '../../sockets' +import { socketConnect } from '../../sockets' +import { renderSlideIcon } from '../../utils/renderSlideIcon' +import SlideDisplay from '../presentationEditor/components/SlideDisplay' import { SlideListItem } from '../presentationEditor/styled' import JudgeScoreDisplay from './components/JudgeScoreDisplay' -import PresentationComponent from './components/PresentationComponent' -import { useHistory } from 'react-router-dom' +import JudgeScoringInstructions from './components/JudgeScoringInstructions' import { Content, InnerContent, @@ -18,13 +20,10 @@ import { JudgeToolbar, LeftDrawer, RightDrawer, + ScoreFooterPadding, ScoreHeaderPadding, ScoreHeaderPaper, - ScoreFooterPadding, } from './styled' -import SlideDisplay from '../presentationEditor/components/SlideDisplay' -import JudgeScoringInstructions from './components/JudgeScoringInstructions' -import { renderSlideIcon } from '../../utils/renderSlideIcon' const leftDrawerWidth = 150 const rightDrawerWidth = 700 @@ -40,30 +39,25 @@ const useStyles = makeStyles((theme: Theme) => toolbar: theme.mixins.toolbar, }) ) -type JudgeViewPageProps = { - //Prop to distinguish between editor and active competition - competitionId: number - code: string -} -const JudgeViewPage = ({ competitionId, code }: JudgeViewPageProps) => { +const JudgeViewPage: React.FC = () => { const classes = useStyles() const history = useHistory() const dispatch = useAppDispatch() const [activeSlideIndex, setActiveSlideIndex] = useState<number>(0) + const viewTypes = useAppSelector((state) => state.types.viewTypes) + const activeViewTypeId = viewTypes.find((viewType) => viewType.name === 'Team')?.id const teams = useAppSelector((state) => state.presentation.competition.teams) const slides = useAppSelector((state) => state.presentation.competition.slides) const currentQuestion = slides[activeSlideIndex]?.questions[0] + const { competitionId }: ViewParams = useParams() const handleSelectSlide = (index: number) => { setActiveSlideIndex(index) dispatch(setCurrentSlide(slides[index])) } useEffect(() => { - socket_connect() + socketConnect() dispatch(getPresentationCompetition(competitionId.toString())) - dispatch(setPresentationCode(code)) - //hides the url so people can't sneak peak - history.push('judge') }, []) return ( @@ -128,7 +122,7 @@ const JudgeViewPage = ({ competitionId, code }: JudgeViewPageProps) => { <div className={classes.toolbar} /> <Content leftDrawerWidth={leftDrawerWidth} rightDrawerWidth={rightDrawerWidth}> <InnerContent> - <SlideDisplay /> + {activeViewTypeId && <SlideDisplay variant="presentation" activeViewTypeId={activeViewTypeId} />} </InnerContent> </Content> </div> diff --git a/client/src/pages/views/PresenterViewPage.test.tsx b/client/src/pages/views/OperatorViewPage.test.tsx similarity index 91% rename from client/src/pages/views/PresenterViewPage.test.tsx rename to client/src/pages/views/OperatorViewPage.test.tsx index fd7b0a9692e08354330b3e4db98c046649c0d10a..e658fe3b386899b9ab2972243c5d4ceeefc6dec4 100644 --- a/client/src/pages/views/PresenterViewPage.test.tsx +++ b/client/src/pages/views/OperatorViewPage.test.tsx @@ -4,7 +4,7 @@ import React from 'react' import { Provider } from 'react-redux' import { BrowserRouter } from 'react-router-dom' import store from '../../store' -import PresenterViewPage from './PresenterViewPage' +import OperatorViewPage from './OperatorViewPage' it('renders presenter view page', () => { const compRes: any = { @@ -36,7 +36,7 @@ it('renders presenter view page', () => { render( <BrowserRouter> <Provider store={store}> - <PresenterViewPage /> + <OperatorViewPage /> </Provider> </BrowserRouter> ) diff --git a/client/src/pages/views/OperatorViewPage.tsx b/client/src/pages/views/OperatorViewPage.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0dbf2765f85ee68843dc80aa2d0aa22015c8d5cb --- /dev/null +++ b/client/src/pages/views/OperatorViewPage.tsx @@ -0,0 +1,315 @@ +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + List, + ListItem, + ListItemText, + Popover, + Tooltip, + Typography, + useMediaQuery, + useTheme, +} from '@material-ui/core' +import AssignmentIcon from '@material-ui/icons/Assignment' +import BackspaceIcon from '@material-ui/icons/Backspace' +import ChevronLeftIcon from '@material-ui/icons/ChevronLeft' +import ChevronRightIcon from '@material-ui/icons/ChevronRight' +import FileCopyIcon from '@material-ui/icons/FileCopy' +import SupervisorAccountIcon from '@material-ui/icons/SupervisorAccount' +import TimerIcon from '@material-ui/icons/Timer' +import React, { useEffect } from 'react' +import { useHistory, useParams } from 'react-router-dom' +import { getPresentationCompetition } from '../../actions/presentation' +import { useAppDispatch, useAppSelector } from '../../hooks' +import { ViewParams } from '../../interfaces/ViewParams' +import { + socketConnect, + socketEndPresentation, + socketSetSlide, + socketSetSlideNext, + socketSetSlidePrev, + socketStartPresentation, + socketStartTimer, +} from '../../sockets' +import SlideDisplay from '../presentationEditor/components/SlideDisplay' +import Timer from './components/Timer' +import { + OperatorButton, + OperatorContainer, + OperatorContent, + OperatorFooter, + OperatorHeader, + OperatorInnerContent, + SlideCounter, + ToolBarContainer, +} from './styled' + +/** + * Description: + * + * Presentation is an active competition + * + * + * =========================================== + * TODO: + * - Instead of copying code for others to join the competition, copy URL. + * + * - Make code popup less code by using .map instead + * + * - 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. + * =========================================== + */ + +const OperatorViewPage: React.FC = () => { + // for dialog alert + const [openAlert, setOpen] = React.useState(false) + const [openAlertCode, setOpenCode] = React.useState(true) + const theme = useTheme() + const fullScreen = useMediaQuery(theme.breakpoints.down('sm')) + const teams = useAppSelector((state) => state.presentation.competition.teams) + const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null) + const { competitionId }: ViewParams = useParams() + const presentation = useAppSelector((state) => state.presentation) + const history = useHistory() + const dispatch = useAppDispatch() + const viewTypes = useAppSelector((state) => state.types.viewTypes) + const activeViewTypeId = viewTypes.find((viewType) => viewType.name === 'Audience')?.id + + useEffect(() => { + dispatch(getPresentationCompetition(competitionId)) + socketConnect() + socketSetSlide // Behövs denna? + setTimeout(startCompetition, 1000) // Ghetto, wait for everything to load + // console.log(id) + }, []) + + window.onpopstate = () => { + //Handle browser back arrow + alert('Tävlingen avslutas för alla') + endCompetition() + } + + const handleOpenPopover = (event: React.MouseEvent<HTMLButtonElement>) => { + setAnchorEl(event.currentTarget) + } + + const handleClose = () => { + setOpen(false) + setOpenCode(false) + setAnchorEl(null) + } + + const startCompetition = () => { + socketStartPresentation() + console.log('started competition for') + console.log(competitionId) + } + + const handleVerifyExit = () => { + setOpen(true) + } + + const handleOpenCodes = () => { + setOpenCode(true) + } + + const handleCopy = () => { + console.log('copied code to clipboard') + } + + const endCompetition = () => { + setOpen(false) + socketEndPresentation() + history.push('/admin/tävlingshanterare') + window.location.reload(false) // TODO: fix this ugly hack, we "need" to refresh site to be able to run the competition correctly again + } + + return ( + <OperatorContainer> + <Dialog + fullScreen={fullScreen} + open={openAlertCode} + onClose={handleClose} + aria-labelledby="responsive-dialog-title" + > + <DialogTitle id="responsive-dialog-title">{'Koder för tävlingen'}</DialogTitle> + <DialogContent> + <ListItem> + <ListItemText primary={`Domare: ${presentation.code}`} /> + <Tooltip title="Kopiera kod" arrow> + <Button + onClick={() => { + navigator.clipboard.writeText(presentation.code) + }} + > + <FileCopyIcon fontSize="small" /> + </Button> + </Tooltip> + </ListItem> + <ListItem> + <ListItemText primary={`Tävlande: ${presentation.code}`} /> + <Tooltip title="Kopiera kod" arrow> + <Button + onClick={() => { + navigator.clipboard.writeText(presentation.code) + }} + > + <FileCopyIcon fontSize="small" /> + </Button> + </Tooltip> + </ListItem> + <ListItem> + <ListItemText primary={`Publik: ${presentation.code}`} /> + <Tooltip title="Kopiera kod" arrow> + <Button + onClick={() => { + navigator.clipboard.writeText(presentation.code) + }} + > + <FileCopyIcon fontSize="small" /> + </Button> + </Tooltip> + </ListItem> + </DialogContent> + <DialogActions> + <Button autoFocus onClick={handleClose} color="primary"> + Ok + </Button> + </DialogActions> + </Dialog> + + <OperatorHeader> + <Tooltip title="Avsluta tävling" arrow> + <OperatorButton onClick={handleVerifyExit} variant="contained" color="secondary"> + <BackspaceIcon fontSize="large" /> + </OperatorButton> + </Tooltip> + + <Dialog + fullScreen={fullScreen} + open={openAlert} + onClose={handleClose} + aria-labelledby="responsive-dialog-title" + > + <DialogTitle id="responsive-dialog-title">{'Vill du avsluta tävlingen?'}</DialogTitle> + <DialogContent> + <DialogContentText> + Genom att avsluta tävlingen kommer den avslutas för alla. Du kommer gå tillbaka till startsidan. + </DialogContentText> + </DialogContent> + <DialogActions> + <Button autoFocus onClick={handleClose} color="primary"> + Avbryt + </Button> + <Button onClick={endCompetition} color="primary" autoFocus> + Avsluta tävling + </Button> + </DialogActions> + </Dialog> + <Typography variant="h3">{presentation.competition.name}</Typography> + <SlideCounter> + <Typography variant="h3"> + {presentation.slide.order + 1} / {presentation.competition.slides.length} + </Typography> + </SlideCounter> + </OperatorHeader> + <div style={{ height: 0, paddingTop: 120 }} /> + <OperatorContent> + <OperatorInnerContent> + {activeViewTypeId && <SlideDisplay variant="presentation" activeViewTypeId={activeViewTypeId} />} + </OperatorInnerContent> + </OperatorContent> + <div style={{ height: 0, paddingTop: 140 }} /> + <OperatorFooter> + <ToolBarContainer> + <Tooltip title="Föregående" arrow> + <OperatorButton onClick={socketSetSlidePrev} variant="contained"> + <ChevronLeftIcon fontSize="large" /> + </OperatorButton> + </Tooltip> + + {/* + // Manual start button + <Tooltip title="Start Presentation" arrow> + <OperatorButton onClick={startCompetition} variant="contained"> + start + </OperatorButton> + </Tooltip> + + + // This creates a join button, but Operator should not join others, others should join Operator + <Tooltip title="Join Presentation" arrow> + <OperatorButton onClick={socketJoinPresentation} variant="contained"> + <GroupAddIcon fontSize="large" /> + </OperatorButton> + </Tooltip> + + + // This creates another end button, it might not be needed since we already have one + <Tooltip title="End Presentation" arrow> + <OperatorButton onClick={socketEndPresentation} variant="contained"> + <CancelIcon fontSize="large" /> + </OperatorButton> + </Tooltip> + */} + + <Tooltip title="Starta Timer" arrow> + <OperatorButton onClick={socketStartTimer} variant="contained"> + <TimerIcon fontSize="large" /> + <Timer></Timer> + </OperatorButton> + </Tooltip> + + <Tooltip title="Ställning" arrow> + <OperatorButton onClick={handleOpenPopover} variant="contained"> + <AssignmentIcon fontSize="large" /> + </OperatorButton> + </Tooltip> + + <Tooltip title="Koder" arrow> + <OperatorButton onClick={handleOpenCodes} variant="contained"> + <SupervisorAccountIcon fontSize="large" /> + </OperatorButton> + </Tooltip> + + <Tooltip title="Nästa" arrow> + <OperatorButton onClick={socketSetSlideNext} variant="contained"> + <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', + }} + > + <List> + {teams && + teams.map((team) => ( + <ListItem key={team.id}> + {team.name} score: {team.question_answers}{' '} + </ListItem> + ))} + </List> + </Popover> + </OperatorContainer> + ) +} + +export default OperatorViewPage diff --git a/client/src/pages/views/ParticipantViewPage.tsx b/client/src/pages/views/ParticipantViewPage.tsx deleted file mode 100644 index ffee1ee11857e875314f847fbd3aca58d6bdd9db..0000000000000000000000000000000000000000 --- a/client/src/pages/views/ParticipantViewPage.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import React, { useEffect } from 'react' -import PresentationComponent from './components/PresentationComponent' -import { useHistory } from 'react-router-dom' -import SlideDisplay from '../presentationEditor/components/SlideDisplay' -import { ParticipantContainer } from './styled' -import { socketJoinPresentation, socket_connect } from '../../sockets' -import { useAppSelector } from '../../hooks' - -const ParticipantViewPage: React.FC = () => { - const history = useHistory() - const code = useAppSelector((state) => state.presentation.code) - useEffect(() => { - //hides the url so people can't sneak peak - history.push('participant') - if (code && code !== '') { - socket_connect() - socketJoinPresentation() - } - }, []) - return ( - <ParticipantContainer> - <SlideDisplay /> - </ParticipantContainer> - ) -} - -export default ParticipantViewPage diff --git a/client/src/pages/views/PresenterViewPage.tsx b/client/src/pages/views/PresenterViewPage.tsx deleted file mode 100644 index f221f5a9b6e612243f626e0fafd490ee02d619e8..0000000000000000000000000000000000000000 --- a/client/src/pages/views/PresenterViewPage.tsx +++ /dev/null @@ -1,224 +0,0 @@ -import { - Button, - Dialog, - DialogActions, - DialogContent, - DialogContentText, - DialogTitle, - List, - ListItem, - Popover, - Tooltip, - Typography, - useMediaQuery, - useTheme, -} from '@material-ui/core' -import AssignmentIcon from '@material-ui/icons/Assignment' -import BackspaceIcon from '@material-ui/icons/Backspace' -import ChevronLeftIcon from '@material-ui/icons/ChevronLeft' -import ChevronRightIcon from '@material-ui/icons/ChevronRight' -import TimerIcon from '@material-ui/icons/Timer' -import React, { useEffect } from 'react' -import { useHistory, useParams } from 'react-router-dom' -import { getPresentationCompetition, setPresentationCode } from '../../actions/presentation' -import { useAppDispatch, useAppSelector } from '../../hooks' -import { ViewParams } from '../../interfaces/ViewParams' -import { - socketEndPresentation, - socketSetSlide, - socketSetSlideNext, - socketSetSlidePrev, - socketStartPresentation, - socketStartTimer, - socket_connect, -} from '../../sockets' -import SlideDisplay from '../presentationEditor/components/SlideDisplay' -import PresentationComponent from './components/PresentationComponent' -import Timer from './components/Timer' -import { - PresenterButton, - PresenterContainer, - PresenterContent, - PresenterFooter, - PresenterHeader, - PresenterInnerContent, - SlideCounter, - ToolBarContainer, -} from './styled' - -/** - * Presentation is an active competition - */ - -const PresenterViewPage: React.FC = () => { - // for dialog alert - const [openAlert, setOpen] = React.useState(false) - const theme = useTheme() - const fullScreen = useMediaQuery(theme.breakpoints.down('sm')) - const teams = useAppSelector((state) => state.presentation.competition.teams) - const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null) - const { id, code }: ViewParams = useParams() - const presentation = useAppSelector((state) => state.presentation) - const history = useHistory() - const dispatch = useAppDispatch() - - useEffect(() => { - dispatch(getPresentationCompetition(id)) - dispatch(setPresentationCode(code)) - socket_connect() - socketSetSlide // Behövs denna? - setTimeout(startCompetition, 500) // Ghetto, wait for everything to load - // console.log(id) - }, []) - - const handleOpenPopover = (event: React.MouseEvent<HTMLButtonElement>) => { - setAnchorEl(event.currentTarget) - } - - const handleClose = () => { - setOpen(false) - setAnchorEl(null) - } - - const startCompetition = () => { - socketStartPresentation() - console.log('started competition for') - console.log(id) - } - - const handleVerifyExit = () => { - setOpen(true) - } - - const endCompetition = () => { - setOpen(false) - socketEndPresentation() - history.push('/admin/tävlingshanterare') - window.location.reload(false) // TODO: fix this ugly hack, we "need" to refresh site to be able to run the competition correctly again - } - - return ( - <PresenterContainer> - <PresenterHeader> - <Tooltip title="Avsluta tävling" arrow> - <PresenterButton onClick={handleVerifyExit} variant="contained" color="secondary"> - <BackspaceIcon fontSize="large" /> - </PresenterButton> - </Tooltip> - - <Dialog - fullScreen={fullScreen} - open={openAlert} - onClose={handleClose} - aria-labelledby="responsive-dialog-title" - > - <DialogTitle id="responsive-dialog-title">{'Vill du avsluta tävlingen?'}</DialogTitle> - <DialogContent> - <DialogContentText> - Genom att avsluta tävlingen kommer den avslutas för alla. Du kommer gå tillbaka till startsidan. - </DialogContentText> - </DialogContent> - <DialogActions> - <Button autoFocus onClick={handleClose} color="primary"> - Avbryt - </Button> - <Button onClick={endCompetition} color="primary" autoFocus> - Avsluta tävling - </Button> - </DialogActions> - </Dialog> - <Typography variant="h3">{presentation.competition.name}</Typography> - <SlideCounter> - <Typography variant="h3"> - {presentation.slide.order + 1} / {presentation.competition.slides.length} - </Typography> - </SlideCounter> - </PresenterHeader> - <div style={{ height: 0, paddingTop: 120 }} /> - <PresenterContent> - <PresenterInnerContent> - <SlideDisplay /> - </PresenterInnerContent> - </PresenterContent> - <div style={{ height: 0, paddingTop: 140 }} /> - <PresenterFooter> - <ToolBarContainer> - <Tooltip title="Previous Slide" arrow> - <PresenterButton onClick={socketSetSlidePrev} variant="contained"> - <ChevronLeftIcon fontSize="large" /> - </PresenterButton> - </Tooltip> - - {/* - // Manual start button - <Tooltip title="Start Presentation" arrow> - <PresenterButton onClick={startCompetition} variant="contained"> - start - </PresenterButton> - </Tooltip> - - - // This creates a join button, but presenter should not join others, others should join presenter - <Tooltip title="Join Presentation" arrow> - <PresenterButton onClick={socketJoinPresentation} variant="contained"> - <GroupAddIcon fontSize="large" /> - </PresenterButton> - </Tooltip> - - - // This creates another end button, it might not be needed since we already have one - <Tooltip title="End Presentation" arrow> - <PresenterButton onClick={socketEndPresentation} variant="contained"> - <CancelIcon fontSize="large" /> - </PresenterButton> - </Tooltip> - */} - - <Tooltip title="Start Timer" arrow> - <PresenterButton onClick={socketStartTimer} variant="contained"> - <TimerIcon fontSize="large" /> - <Timer></Timer> - </PresenterButton> - </Tooltip> - - <Tooltip title="Scoreboard" arrow> - <PresenterButton onClick={handleOpenPopover} variant="contained"> - <AssignmentIcon fontSize="large" /> - </PresenterButton> - </Tooltip> - - <Tooltip title="Next Slide" arrow> - <PresenterButton onClick={socketSetSlideNext} variant="contained"> - <ChevronRightIcon fontSize="large" /> - </PresenterButton> - </Tooltip> - </ToolBarContainer> - </PresenterFooter> - <Popover - open={Boolean(anchorEl)} - anchorEl={anchorEl} - onClose={handleClose} - anchorOrigin={{ - vertical: 'bottom', - horizontal: 'center', - }} - transformOrigin={{ - vertical: 'top', - horizontal: 'center', - }} - > - <List> - {/** TODO: - * Fix scoreboard - */} - {teams && teams.map((team) => <ListItem key={team.id}>{team.name} score: 20</ListItem>)} - </List> - </Popover> - </PresenterContainer> - ) -} - -export default PresenterViewPage -function componentDidMount() { - throw new Error('Function not implemented.') -} diff --git a/client/src/pages/views/ParticipantViewPage.test.tsx b/client/src/pages/views/TeamViewPage.test.tsx similarity index 85% rename from client/src/pages/views/ParticipantViewPage.test.tsx rename to client/src/pages/views/TeamViewPage.test.tsx index e25ab6b9ec8f6309ce60812d296c37ffab3042e6..10574f7e51df7dabf9f07754e9d8595d1c489559 100644 --- a/client/src/pages/views/ParticipantViewPage.test.tsx +++ b/client/src/pages/views/TeamViewPage.test.tsx @@ -3,7 +3,7 @@ import React from 'react' import { Provider } from 'react-redux' import { BrowserRouter } from 'react-router-dom' import store from '../../store' -import ParticipantViewPage from './ParticipantViewPage' +import TeamViewPage from './TeamViewPage' import mockedAxios from 'axios' it('renders participant view page', () => { @@ -16,7 +16,7 @@ it('renders participant view page', () => { render( <BrowserRouter> <Provider store={store}> - <ParticipantViewPage /> + <TeamViewPage /> </Provider> </BrowserRouter> ) diff --git a/client/src/pages/views/TeamViewPage.tsx b/client/src/pages/views/TeamViewPage.tsx new file mode 100644 index 0000000000000000000000000000000000000000..13791b3acd7b1ee5688ba3314aa4daa6e04f7272 --- /dev/null +++ b/client/src/pages/views/TeamViewPage.tsx @@ -0,0 +1,33 @@ +import React, { useEffect } from 'react' +import { useHistory, useParams } from 'react-router-dom' +import { getPresentationCompetition } from '../../actions/presentation' +import { useAppDispatch, useAppSelector } from '../../hooks' +import { ViewParams } from '../../interfaces/ViewParams' +import { socketConnect, socketJoinPresentation } from '../../sockets' +import SlideDisplay from '../presentationEditor/components/SlideDisplay' +import { PresentationBackground, PresentationContainer } from './styled' + +const TeamViewPage: React.FC = () => { + const history = useHistory() + const code = useAppSelector((state) => state.presentation.code) + const viewTypes = useAppSelector((state) => state.types.viewTypes) + const activeViewTypeId = viewTypes.find((viewType) => viewType.name === 'Team')?.id + const { competitionId }: ViewParams = useParams() + const dispatch = useAppDispatch() + useEffect(() => { + dispatch(getPresentationCompetition(competitionId)) + if (code && code !== '') { + socketConnect() + socketJoinPresentation() + } + }, []) + return ( + <PresentationBackground> + <PresentationContainer> + {activeViewTypeId && <SlideDisplay variant="presentation" activeViewTypeId={activeViewTypeId} />} + </PresentationContainer> + </PresentationBackground> + ) +} + +export default TeamViewPage diff --git a/client/src/pages/views/ViewSelectPage.tsx b/client/src/pages/views/ViewSelectPage.tsx index 9a0264f3da7651543841a28c5a5ad0f73c3f5aa3..d69dc8b4243d680647802fe9d6f72acbd23618ba 100644 --- a/client/src/pages/views/ViewSelectPage.tsx +++ b/client/src/pages/views/ViewSelectPage.tsx @@ -1,5 +1,5 @@ import { CircularProgress, Typography } from '@material-ui/core' -import React, { useEffect, useState } from 'react' +import React, { useEffect } from 'react' import { Redirect, useHistory, useParams } from 'react-router-dom' import { loginCompetition } from '../../actions/competitionLogin' import { useAppDispatch, useAppSelector } from '../../hooks' @@ -11,56 +11,47 @@ interface ViewSelectParams { const ViewSelectPage: React.FC = () => { const dispatch = useAppDispatch() const history = useHistory() - const [loading, setLoading] = useState(true) - const [error, setError] = useState(false) - const [viewTypeId, setViewTypeId] = useState(undefined) - const [competitionId, setCompetitionId] = useState<number | undefined>(undefined) + const competitionId = useAppSelector((state) => state.competitionLogin.data?.competition_id) + const errorMessage = useAppSelector((state) => state.competitionLogin.errors?.message) + const loading = useAppSelector((state) => state.competitionLogin.loading) const { code }: ViewSelectParams = useParams() - const viewType = useAppSelector((state) => state.types.viewTypes.find((viewType) => viewType.id === viewTypeId)?.name) + const viewType = useAppSelector((state) => state.competitionLogin.data?.view) - const renderView = (viewTypeId: number | undefined) => { + const renderView = () => { //Renders the correct view depending on view type if (competitionId) { switch (viewType) { case 'Team': - return <Redirect to={`/team/${competitionId}`} /> + return <Redirect to={`/team/competition-id=${competitionId}`} /> case 'Judge': - return <Redirect to={`/judge/${competitionId}`} /> + return <Redirect to={`/judge/competition-id=${competitionId}`} /> case 'Audience': - return <Redirect to={`/audience/${competitionId}`} /> + return <Redirect to={`/audience/competition-id=${competitionId}`} /> case 'Operator': - return <Redirect to={`/operator/${competitionId}`} /> + return <Redirect to={`/operator/competition-id=${competitionId}`} /> default: - return <Typography>Inkorrekt vy</Typography> + return ( + <ViewSelectContainer> + <ViewSelectButtonGroup> + <Typography variant="h4">Inkorrekt vy</Typography> + </ViewSelectButtonGroup> + </ViewSelectContainer> + ) } } } - useEffect(() => { - dispatch(loginCompetition(code, history)) - /* axios - .post('/api/auth/login/code', { code }) - .then((response) => { - setLoading(false) - setViewTypeId(response.data.view_type_id) - setCompetitionId(response.data.competition_id) - dispatch(getPresentationCompetition(response.data.competition_id)) - dispatch(setPresentationCode(code)) - }) - .catch(() => { - setLoading(false) - setError(true) - }) */ + dispatch(loginCompetition(code, history, false)) }, []) return ( <> - {!loading && renderView(viewTypeId)} - {(loading || error) && ( + {renderView()} + {(loading || errorMessage) && ( <ViewSelectContainer> <ViewSelectButtonGroup> {loading && <CircularProgress />} - {error && <Typography>Något gick fel, dubbelkolla koden och försök igen</Typography>} + {errorMessage && <Typography variant="h4">{errorMessage}</Typography>} </ViewSelectButtonGroup> </ViewSelectContainer> )} diff --git a/client/src/pages/views/components/SocketTest.tsx b/client/src/pages/views/components/SocketTest.tsx index d99f5b2aa740d9a2690b3aadeae6e1050b92d089..01a0a6f29b51d9a194cb397fdc73c720202e0c1c 100644 --- a/client/src/pages/views/components/SocketTest.tsx +++ b/client/src/pages/views/components/SocketTest.tsx @@ -2,13 +2,13 @@ import React, { useEffect } from 'react' import { connect } from 'react-redux' import { useAppDispatch } from '../../../hooks' import { + socketConnect, socketEndPresentation, socketJoinPresentation, socketSetSlideNext, socketSetSlidePrev, socketStartPresentation, socketStartTimer, - socket_connect, } from '../../../sockets' const mapStateToProps = (state: any) => { @@ -27,7 +27,7 @@ const SocketTest: React.FC = (props: any) => { const dispatch = useAppDispatch() useEffect(() => { - socket_connect() + socketConnect() // dispatch(getPresentationCompetition('1')) // TODO: Use ID of item_code gotten from auth/login/<code> api call // dispatch(getPresentationTeams('1')) // TODO: Use ID of item_code gotten from auth/login/<code> api call }, []) diff --git a/client/src/pages/views/styled.tsx b/client/src/pages/views/styled.tsx index 8f63892ade7928842c12d4dc73df988a505f4cea..4b01d63ab8df48957ab88619424a089f96f36ebd 100644 --- a/client/src/pages/views/styled.tsx +++ b/client/src/pages/views/styled.tsx @@ -1,4 +1,4 @@ -import { AppBar, Button, Card, Drawer, Paper, Toolbar, Typography } from '@material-ui/core' +import { AppBar, Button, Card, Drawer, Toolbar, Typography } from '@material-ui/core' import styled from 'styled-components' export const JudgeAppBar = styled(AppBar)` @@ -35,7 +35,7 @@ export const ViewSelectButtonGroup = styled.div` margin-right: auto; ` -export const PresenterHeader = styled.div` +export const OperatorHeader = styled.div` display: flex; justify-content: space-between; height: 120px; @@ -43,7 +43,7 @@ export const PresenterHeader = styled.div` position: absolute; ` -export const PresenterFooter = styled.div` +export const OperatorFooter = styled.div` display: flex; justify-content: space-between; height: 140px; @@ -52,7 +52,7 @@ export const PresenterFooter = styled.div` width: 100%; ` -export const PresenterButton = styled(Button)` +export const OperatorButton = styled(Button)` width: 100px; height: 100px; margin-left: 16px; @@ -66,7 +66,7 @@ export const SlideCounter = styled(Button)` margin-top: 16px; ` -export const PresenterContainer = styled.div` +export const OperatorContainer = styled.div` display: flex; flex-direction: column; justify-content: space-between; @@ -127,7 +127,7 @@ export const InnerContent = styled.div` max-width: calc(((100vh - 64px) / 9) * 16); ` -export const PresenterContent = styled.div` +export const OperatorContent = styled.div` height: 100%; width: 100%; display: flex; @@ -135,7 +135,7 @@ export const PresenterContent = styled.div` background-color: rgba(0, 0, 0, 0.08); ` -export const PresenterInnerContent = styled.div` +export const OperatorInnerContent = styled.div` height: 100%; width: 100%; /* Makes sure width is not bigger than where a 16:9 display can fit @@ -143,10 +143,20 @@ export const PresenterInnerContent = styled.div` max-width: calc(((100vh - 260px) / 9) * 16); ` -export const ParticipantContainer = styled.div` +export const PresentationContainer = styled.div` + height: 100%; + width: 100%; max-width: calc((100vh / 9) * 16); ` +export const PresentationBackground = styled.div` + height: 100%; + width: 100%; + background-color: rgba(0, 0, 0, 0.08); + display: flex; + justify-content: center; +` + interface ScoreHeaderPaperProps { $rightDrawerWidth: number } diff --git a/client/src/reducers/competitionLoginReducer.ts b/client/src/reducers/competitionLoginReducer.ts index c8802ef7b64929815baa8155bc4fee49359968c9..6d425f97209c810cd1646c3f9722dd9ec5666ff8 100644 --- a/client/src/reducers/competitionLoginReducer.ts +++ b/client/src/reducers/competitionLoginReducer.ts @@ -1,6 +1,12 @@ import { AnyAction } from 'redux' import Types from '../actions/types' +interface CompetitionLoginData { + competition_id: number + team_id: number | null + view: string +} + interface UIError { message: string } @@ -9,20 +15,33 @@ interface CompetitionLoginState { loading: boolean errors: null | UIError authenticated: boolean + data: CompetitionLoginData | null + initialized: boolean } const initialState: CompetitionLoginState = { loading: false, errors: null, authenticated: false, + data: null, + initialized: false, } export default function (state = initialState, action: AnyAction) { switch (action.type) { + case Types.SET_COMPETITION_LOGIN_DATA: + return { + ...state, + data: action.payload as CompetitionLoginData, + authenticated: true, + initialized: true, + } + case Types.SET_COMPETITION_LOGIN_AUTHENTICATED: return { ...state, authenticated: true, + initialized: true, } case Types.SET_COMPETITION_LOGIN_ERRORS: return { diff --git a/client/src/reducers/editorReducer.ts b/client/src/reducers/editorReducer.ts index 88e6e3687ea6f4348ebd5956e677ef26319e33c2..4f245d1d05638cc059cf8d26921642d8239c169a 100644 --- a/client/src/reducers/editorReducer.ts +++ b/client/src/reducers/editorReducer.ts @@ -5,6 +5,7 @@ import { RichCompetition } from '../interfaces/ApiRichModels' interface EditorState { competition: RichCompetition activeSlideId: number + activeViewTypeId: number loading: boolean } @@ -19,6 +20,7 @@ const initialState: EditorState = { background_image: undefined, }, activeSlideId: -1, + activeViewTypeId: -1, loading: true, } @@ -35,6 +37,11 @@ export default function (state = initialState, action: AnyAction) { ...state, activeSlideId: action.payload as number, } + case Types.SET_EDITOR_VIEW_ID: + return { + ...state, + activeViewTypeId: action.payload as number, + } default: return state } diff --git a/client/src/sockets.ts b/client/src/sockets.ts index 4392021df3c89d3d981096e38e1576d155c89b21..e5addeb8973ee5e346c44e1a3ca40b417e18db4d 100644 --- a/client/src/sockets.ts +++ b/client/src/sockets.ts @@ -18,9 +18,19 @@ interface SetTimerInterface { let socket: SocketIOClient.Socket -export const socket_connect = () => { +export const socketConnect = () => { if (!socket) { - socket = io('localhost:5000') + const token = localStorage.competitionToken + console.log(token) + socket = io('localhost:5000', { + transportOptions: { + polling: { + extraHeaders: { + Authorization: token, + }, + }, + }, + }) socket.on('set_slide', (data: SetSlideInterface) => { setCurrentSlideByOrder(data.slide_order)(store.dispatch) diff --git a/client/src/utils/SecureRoute.tsx b/client/src/utils/SecureRoute.tsx index cfdf52f602668222e24e5e20e095e6a62da59e5e..e2885501748c468eb898e833481ae219516c5946 100644 --- a/client/src/utils/SecureRoute.tsx +++ b/client/src/utils/SecureRoute.tsx @@ -1,42 +1,45 @@ -import React, { useEffect } from 'react' +import React from 'react' import { Redirect, Route, RouteProps } from 'react-router-dom' import { useAppSelector } from '../hooks' import { CheckAuthenticationAdmin } from './checkAuthenticationAdmin' import { CheckAuthenticationCompetition } from './checkAuthenticationCompetition' interface SecureRouteProps extends RouteProps { - login?: boolean component: React.ComponentType<any> rest?: any authLevel: 'competition' | 'admin' | 'login' } + /** Utility component to use for authentication, replace all routes that should be private with secure routes*/ -const SecureRoute: React.FC<SecureRouteProps> = ({ - login, - component: Component, - authLevel, - ...rest -}: SecureRouteProps) => { - const authenticated = useAppSelector((state) => state.user.authenticated) +const SecureRoute: React.FC<SecureRouteProps> = ({ component: Component, authLevel, ...rest }: SecureRouteProps) => { + const userAuthenticated = useAppSelector((state) => state.user.authenticated) + const compAuthenticated = useAppSelector((state) => state.competitionLogin.authenticated) const [initialized, setInitialized] = React.useState(false) - useEffect(() => { - const waitForAuthentication = async () => { - if (authLevel === 'admin' || authLevel === 'login') { - await CheckAuthenticationAdmin() - } else { - await CheckAuthenticationCompetition() - } - console.log('initialized') - setInitialized(true) + const compInitialized = useAppSelector((state) => state.competitionLogin.initialized) + React.useEffect(() => { + if (authLevel === 'admin' || authLevel === 'login') { + CheckAuthenticationAdmin().then(() => setInitialized(true)) + } else { + CheckAuthenticationCompetition().then(() => setInitialized(true)) } - waitForAuthentication() }, []) + if (initialized) { if (authLevel === 'login') return ( - <Route {...rest} render={(props) => (authenticated ? <Redirect to="/admin" /> : <Component {...props} />)} /> + <Route + {...rest} + render={(props) => (userAuthenticated ? <Redirect to="/admin" /> : <Component {...props} />)} + /> + ) + else if (authLevel === 'competition' && compInitialized) + return ( + <Route {...rest} render={(props) => (compAuthenticated ? <Component {...props} /> : <Redirect to="/" />)} /> + ) + else + return ( + <Route {...rest} render={(props) => (userAuthenticated ? <Component {...props} /> : <Redirect to="/" />)} /> ) - else return <Route {...rest} render={(props) => (authenticated ? <Component {...props} /> : <Redirect to="/" />)} /> } else return null } export default SecureRoute diff --git a/client/src/utils/checkAuthenticationAdmin.ts b/client/src/utils/checkAuthenticationAdmin.ts index 3565f8e3b93bb8ff83114e166618a22f823d335e..f8f94e2d0c42fbdbaa46283af6b3e2378d1a69be 100644 --- a/client/src/utils/checkAuthenticationAdmin.ts +++ b/client/src/utils/checkAuthenticationAdmin.ts @@ -9,6 +9,7 @@ const UnAuthorized = async () => { } export const CheckAuthenticationAdmin = async () => { + console.log('checkAuthenticationAdmin') const authToken = localStorage.token if (authToken) { const decodedToken: any = jwtDecode(authToken) diff --git a/client/src/utils/checkAuthenticationCompetition.ts b/client/src/utils/checkAuthenticationCompetition.ts index ef3e6c0056b9f8225a232724e7f79ba77016b9fe..db0c32360a9928459dd4cac4e65bfb1715a8353f 100644 --- a/client/src/utils/checkAuthenticationCompetition.ts +++ b/client/src/utils/checkAuthenticationCompetition.ts @@ -1,6 +1,7 @@ import axios from 'axios' import jwtDecode from 'jwt-decode' import { logoutCompetition } from '../actions/competitionLogin' +import { setPresentationCode } from '../actions/presentation' import Types from '../actions/types' import store from '../store' @@ -9,17 +10,25 @@ const UnAuthorized = async () => { } export const CheckAuthenticationCompetition = async () => { - console.log('checkAuthComp') const authToken = localStorage.competitionToken if (authToken) { const decodedToken: any = jwtDecode(authToken) - console.log(decodedToken) if (decodedToken.exp * 1000 >= Date.now()) { axios.defaults.headers.common['Authorization'] = authToken + console.log(decodedToken.user_claims) await axios .get('/api/auth/test') .then((res) => { store.dispatch({ type: Types.SET_COMPETITION_LOGIN_AUTHENTICATED }) + store.dispatch({ + type: Types.SET_COMPETITION_LOGIN_DATA, + payload: { + competition_id: decodedToken.user_claims.competition_id, + team_id: decodedToken.user_claims.team_id, + view: res.data.view, + }, + }) + setPresentationCode(decodedToken.user_claims.code)(store.dispatch) }) .catch((error) => { console.log(error) diff --git a/client/src/utils/uploadImage.ts b/client/src/utils/uploadImage.ts new file mode 100644 index 0000000000000000000000000000000000000000..151a34a400f2d2e632b1f18b8f249206c043a02f --- /dev/null +++ b/client/src/utils/uploadImage.ts @@ -0,0 +1,16 @@ +import axios from 'axios' +import { getEditorCompetition } from '../actions/editor' +import { Media } from '../interfaces/ApiModels' +import store from '../store' + +export const uploadFile = async (formData: FormData, competitionId: string) => { + // Uploads the file to the server and creates a Media object in database. + // Returns media object data. + return await axios + .post(`/api/media/images`, formData) + .then((response) => { + getEditorCompetition(competitionId)(store.dispatch, store.getState) + return response.data as Media + }) + .catch(console.log) +} diff --git a/server/app/apis/alternatives.py b/server/app/apis/alternatives.py index 0ce74d53b0a742ea31689d826f898e1b12f33b41..94f6d6b1725106a3cf5994570e4d3d5829ba2dbe 100644 --- a/server/app/apis/alternatives.py +++ b/server/app/apis/alternatives.py @@ -4,14 +4,19 @@ from app.apis import item_response, list_response, protect_route from app.core.dto import QuestionAlternativeDTO from flask_restx import Resource from flask_restx import reqparse +from app.core.parsers import sentinel api = QuestionAlternativeDTO.api schema = QuestionAlternativeDTO.schema list_schema = QuestionAlternativeDTO.list_schema -question_alternative_parser = reqparse.RequestParser() -question_alternative_parser.add_argument("text", type=str, default=None, location="json") -question_alternative_parser.add_argument("value", type=int, default=None, location="json") +alternative_parser_add = reqparse.RequestParser() +alternative_parser_add.add_argument("text", type=str, required=True, location="json") +alternative_parser_add.add_argument("value", type=int, required=True, location="json") + +alternative_parser_edit = reqparse.RequestParser() +alternative_parser_edit.add_argument("text", type=str, default=sentinel, location="json") +alternative_parser_edit.add_argument("value", type=int, default=sentinel, location="json") @api.route("") @@ -24,7 +29,7 @@ class QuestionAlternativeList(Resource): @protect_route(allowed_roles=["*"]) def post(self, competition_id, slide_id, question_id): - args = question_alternative_parser.parse_args(strict=True) + args = alternative_parser_add.parse_args(strict=True) item = dbc.add.question_alternative(**args, question_id=question_id) return item_response(schema.dump(item)) @@ -39,7 +44,7 @@ class QuestionAlternatives(Resource): @protect_route(allowed_roles=["*"]) def put(self, competition_id, slide_id, question_id, alternative_id): - args = question_alternative_parser.parse_args(strict=True) + args = alternative_parser_edit.parse_args(strict=True) item = dbc.get.question_alternative(competition_id, slide_id, question_id, alternative_id) item = dbc.edit.default(item, **args) return item_response(schema.dump(item)) diff --git a/server/app/apis/answers.py b/server/app/apis/answers.py index 8f72d3bde8610c255083df9deefc69b89a68560a..3990e4e1d7b75fa35d13cc592906e4c4aa921024 100644 --- a/server/app/apis/answers.py +++ b/server/app/apis/answers.py @@ -4,19 +4,20 @@ from app.apis import item_response, list_response, protect_route from app.core.dto import QuestionAnswerDTO from flask_restx import Resource from flask_restx import reqparse +from app.core.parsers import sentinel api = QuestionAnswerDTO.api schema = QuestionAnswerDTO.schema list_schema = QuestionAnswerDTO.list_schema -question_answer_parser = reqparse.RequestParser() -question_answer_parser.add_argument("answer", type=str, required=True, location="json") -question_answer_parser.add_argument("score", type=int, required=True, location="json") -question_answer_parser.add_argument("question_id", type=int, required=True, location="json") +answer_parser_add = reqparse.RequestParser() +answer_parser_add.add_argument("answer", type=str, required=True, location="json") +answer_parser_add.add_argument("score", type=int, required=True, location="json") +answer_parser_add.add_argument("question_id", type=int, required=True, location="json") -question_answer_edit_parser = reqparse.RequestParser() -question_answer_edit_parser.add_argument("answer", type=str, default=None, location="json") -question_answer_edit_parser.add_argument("score", type=int, default=None, location="json") +answer_parser_edit = reqparse.RequestParser() +answer_parser_edit.add_argument("answer", type=str, default=sentinel, location="json") +answer_parser_edit.add_argument("score", type=int, default=sentinel, location="json") @api.route("") @@ -29,7 +30,7 @@ class QuestionAnswerList(Resource): @protect_route(allowed_roles=["*"], allowed_views=["*"]) def post(self, competition_id, team_id): - args = question_answer_parser.parse_args(strict=True) + args = answer_parser_add.parse_args(strict=True) item = dbc.add.question_answer(**args, team_id=team_id) return item_response(schema.dump(item)) @@ -44,7 +45,7 @@ class QuestionAnswers(Resource): @protect_route(allowed_roles=["*"], allowed_views=["*"]) def put(self, competition_id, team_id, answer_id): - args = question_answer_edit_parser.parse_args(strict=True) + args = answer_parser_edit.parse_args(strict=True) item = dbc.get.question_answer(competition_id, team_id, answer_id) item = dbc.edit.default(item, **args) return item_response(schema.dump(item)) diff --git a/server/app/apis/auth.py b/server/app/apis/auth.py index 62795e3afd0ee1f846e3e24aa612784e9b56d9fe..a3be209155aa5fc03b1dcd16478bd94e51f45826 100644 --- a/server/app/apis/auth.py +++ b/server/app/apis/auth.py @@ -17,14 +17,14 @@ list_schema = AuthDTO.list_schema login_parser = reqparse.RequestParser() login_parser.add_argument("email", type=inputs.email(), required=True, location="json") -login_parser.add_argument("password", required=True, location="json") +login_parser.add_argument("password", type=str, required=True, location="json") create_user_parser = login_parser.copy() create_user_parser.add_argument("city_id", type=int, required=True, location="json") create_user_parser.add_argument("role_id", type=int, required=True, location="json") login_code_parser = reqparse.RequestParser() -login_code_parser.add_argument("code", type=str, location="json") +login_code_parser.add_argument("code", type=str, required=True, location="json") def get_user_claims(item_user): @@ -32,7 +32,14 @@ def get_user_claims(item_user): def get_code_claims(item_code): - return {"view": item_code.view_type.name, "competition_id": item_code.competition_id, "team_id": item_code.team_id} + return {"view": item_code.view_type.name, "competition_id": item_code.competition_id, "team_id": item_code.team_id, "code": item_code.code} + + +@api.route("/test") +class AuthSignup(Resource): + @protect_route(allowed_roles=["Admin"], allowed_views=["*"]) + def get(self): + return "ok" @api.route("/signup") @@ -102,7 +109,7 @@ class AuthLoginCode(Resource): response = { "competition_id": item_code.competition_id, - "view_type_id": item_code.view_type_id, + "view": item_code.view_type.name, "team_id": item_code.team_id, "access_token": access_token, } diff --git a/server/app/apis/competitions.py b/server/app/apis/competitions.py index d1fd95722f026cb3aa67e9bb8354a6823af9bbee..c5ff37c9131a7a9ec4bc1f394a9e1f5b5727460e 100644 --- a/server/app/apis/competitions.py +++ b/server/app/apis/competitions.py @@ -6,32 +6,35 @@ from app.core.dto import CompetitionDTO from app.database.models import Competition from flask_restx import Resource from flask_restx import reqparse -from app.core.parsers import search_parser +from app.core.parsers import search_parser, sentinel api = CompetitionDTO.api schema = CompetitionDTO.schema rich_schema = CompetitionDTO.rich_schema list_schema = CompetitionDTO.list_schema -competition_parser = reqparse.RequestParser() -competition_parser.add_argument("name", type=str, location="json") -competition_parser.add_argument("year", type=int, location="json") -competition_parser.add_argument("city_id", type=int, location="json") +competition_parser_add = reqparse.RequestParser() +competition_parser_add.add_argument("name", type=str, required=True, location="json") +competition_parser_add.add_argument("year", type=int, required=True, location="json") +competition_parser_add.add_argument("city_id", type=int, required=True, location="json") -competition_edit_parser = competition_parser.copy() -competition_edit_parser.add_argument("background_image_id", default=None, type=int, location="json") +competition_parser_edit = reqparse.RequestParser() +competition_parser_edit.add_argument("name", type=str, default=sentinel, location="json") +competition_parser_edit.add_argument("year", type=int, default=sentinel, location="json") +competition_parser_edit.add_argument("city_id", type=int, default=sentinel, location="json") +competition_parser_edit.add_argument("background_image_id", default=sentinel, type=int, location="json") -competition_search_parser = search_parser.copy() -competition_search_parser.add_argument("name", type=str, default=None, location="args") -competition_search_parser.add_argument("year", type=int, default=None, location="args") -competition_search_parser.add_argument("city_id", type=int, default=None, location="args") +competition_parser_search = search_parser.copy() +competition_parser_search.add_argument("name", type=str, default=sentinel, location="args") +competition_parser_search.add_argument("year", type=int, default=sentinel, location="args") +competition_parser_search.add_argument("city_id", type=int, default=sentinel, location="args") @api.route("") class CompetitionsList(Resource): @protect_route(allowed_roles=["*"]) def post(self): - args = competition_parser.parse_args(strict=True) + args = competition_parser_add.parse_args(strict=True) # Add competition item = dbc.add.competition(**args) @@ -52,9 +55,9 @@ class Competitions(Resource): @protect_route(allowed_roles=["*"]) def put(self, competition_id): - args = competition_edit_parser.parse_args(strict=True) + args = competition_parser_edit.parse_args(strict=True) item = dbc.get.one(Competition, competition_id) - item = dbc.edit.competition(item, **args) + item = dbc.edit.default(item, **args) return item_response(schema.dump(item)) @@ -70,7 +73,7 @@ class Competitions(Resource): class CompetitionSearch(Resource): @protect_route(allowed_roles=["*"]) def get(self): - args = competition_search_parser.parse_args(strict=True) + args = competition_parser_search.parse_args(strict=True) items, total = dbc.search.competition(**args) return list_response(list_schema.dump(items), total) diff --git a/server/app/apis/components.py b/server/app/apis/components.py index c22ce4ad671329538e05a6a6ee7bb5fd9026ca38..a1982763a5ec37b8bb02bb8e6a73e164355417a8 100644 --- a/server/app/apis/components.py +++ b/server/app/apis/components.py @@ -4,24 +4,31 @@ from app.apis import item_response, list_response, protect_route from app.core.dto import ComponentDTO from flask_restx import Resource from flask_restx import reqparse +from app.core.parsers import sentinel api = ComponentDTO.api schema = ComponentDTO.schema list_schema = ComponentDTO.list_schema +component_parser_add = reqparse.RequestParser() +component_parser_add.add_argument("x", type=str, default=0, location="json") +component_parser_add.add_argument("y", type=int, default=0, location="json") +component_parser_add.add_argument("w", type=int, default=1, location="json") +component_parser_add.add_argument("h", type=int, default=1, location="json") +component_parser_add.add_argument("type_id", type=int, required=True, location="json") +component_parser_add.add_argument("view_type_id", type=int, required=True, location="json") +component_parser_add.add_argument("text", type=str, default=None, location="json") +component_parser_add.add_argument("media_id", type=int, default=None, location="json") +component_parser_add.add_argument("question_id", type=int, default=None, location="json") -component_parser = reqparse.RequestParser() -component_parser.add_argument("x", type=str, default=None, location="json") -component_parser.add_argument("y", type=int, default=None, location="json") -component_parser.add_argument("w", type=int, default=None, location="json") -component_parser.add_argument("h", type=int, default=None, location="json") - -component_edit_parser = component_parser.copy() -component_edit_parser.add_argument("text", type=str, location="json") -component_edit_parser.add_argument("media_id", type=str, location="json") - -component_create_parser = component_edit_parser.copy() -component_create_parser.add_argument("type_id", type=int, required=True, location="json") +component_parser_edit = reqparse.RequestParser() +component_parser_edit.add_argument("x", type=str, default=sentinel, location="json") +component_parser_edit.add_argument("y", type=int, default=sentinel, location="json") +component_parser_edit.add_argument("w", type=int, default=sentinel, location="json") +component_parser_edit.add_argument("h", type=int, default=sentinel, location="json") +component_parser_edit.add_argument("text", type=str, default=sentinel, location="json") +component_parser_edit.add_argument("media_id", type=int, default=sentinel, location="json") +component_parser_edit.add_argument("question_id", type=int, default=sentinel, location="json") @api.route("/<component_id>") @@ -34,10 +41,10 @@ class ComponentByID(Resource): @protect_route(allowed_roles=["*"]) def put(self, competition_id, slide_id, component_id): - args = component_edit_parser.parse_args(strict=True) + args = component_parser_edit.parse_args(strict=True) item = dbc.get.component(competition_id, slide_id, component_id) - args_without_none = {key: value for key, value in args.items() if value is not None} - item = dbc.edit.default(item, **args_without_none) + args_without_sentinel = {key: value for key, value in args.items() if value is not sentinel} + item = dbc.edit.default(item, **args_without_sentinel) return item_response(schema.dump(item)) @protect_route(allowed_roles=["*"]) @@ -57,6 +64,6 @@ class ComponentList(Resource): @protect_route(allowed_roles=["*"]) def post(self, competition_id, slide_id): - args = component_create_parser.parse_args() + args = component_parser_add.parse_args() item = dbc.add.component(slide_id=slide_id, **args) return item_response(schema.dump(item)) diff --git a/server/app/apis/media.py b/server/app/apis/media.py index c7de8c4d4c1cbd3f482d386e654a9bfd0063370b..49d20608840e320ef3d73d9df848748e6da33617 100644 --- a/server/app/apis/media.py +++ b/server/app/apis/media.py @@ -10,6 +10,7 @@ from flask_restx import Resource from flask_uploads import UploadNotAllowed from sqlalchemy import exc import app.core.files as files +from app.core.parsers import sentinel api = MediaDTO.api image_set = MediaDTO.image_set @@ -17,7 +18,7 @@ schema = MediaDTO.schema list_schema = MediaDTO.list_schema media_parser_search = search_parser.copy() -media_parser_search.add_argument("filename", type=str, default=None, location="args") +media_parser_search.add_argument("filename", type=str, default=sentinel, location="args") @api.route("/images") diff --git a/server/app/apis/questions.py b/server/app/apis/questions.py index de40a310db5f11a1b0c03cb20e1546f74682af70..b14849d224501e48f9cce8a108bfd24f29657e99 100644 --- a/server/app/apis/questions.py +++ b/server/app/apis/questions.py @@ -4,15 +4,21 @@ from app.apis import item_response, list_response, protect_route from app.core.dto import QuestionDTO from flask_restx import Resource from flask_restx import reqparse +from app.core.parsers import sentinel api = QuestionDTO.api schema = QuestionDTO.schema list_schema = QuestionDTO.list_schema -question_parser = reqparse.RequestParser() -question_parser.add_argument("name", type=str, default=None, location="json") -question_parser.add_argument("total_score", type=int, default=None, location="json") -question_parser.add_argument("type_id", type=int, default=None, location="json") +question_parser_add = reqparse.RequestParser() +question_parser_add.add_argument("name", type=str, default=None, location="json") +question_parser_add.add_argument("total_score", type=int, default=None, location="json") +question_parser_add.add_argument("type_id", type=int, required=True, location="json") + +question_parser_edit = reqparse.RequestParser() +question_parser_edit.add_argument("name", type=str, default=sentinel, location="json") +question_parser_edit.add_argument("total_score", type=int, default=sentinel, location="json") +question_parser_edit.add_argument("type_id", type=int, default=sentinel, location="json") @api.route("/questions") @@ -34,7 +40,7 @@ class QuestionListForSlide(Resource): @protect_route(allowed_roles=["*"]) def post(self, competition_id, slide_id): - args = question_parser.parse_args(strict=True) + args = question_parser_add.parse_args(strict=True) item = dbc.add.question(slide_id=slide_id, **args) return item_response(schema.dump(item)) @@ -49,7 +55,7 @@ class QuestionById(Resource): @protect_route(allowed_roles=["*"]) def put(self, competition_id, slide_id, question_id): - args = question_parser.parse_args(strict=True) + args = question_parser_edit.parse_args(strict=True) item_question = dbc.get.question(competition_id, slide_id, question_id) item_question = dbc.edit.default(item_question, **args) diff --git a/server/app/apis/slides.py b/server/app/apis/slides.py index 52553796260493507bcc47ef32f8977f241a2a88..52ce4cce1d20fc277c3ee9ec46ea3566a51ec633 100644 --- a/server/app/apis/slides.py +++ b/server/app/apis/slides.py @@ -4,16 +4,17 @@ from app.apis import item_response, list_response, protect_route from app.core.dto import SlideDTO from flask_restx import Resource from flask_restx import reqparse +from app.core.parsers import sentinel api = SlideDTO.api schema = SlideDTO.schema list_schema = SlideDTO.list_schema -slide_parser = reqparse.RequestParser() -slide_parser.add_argument("order", type=int, default=None, location="json") -slide_parser.add_argument("title", type=str, default=None, location="json") -slide_parser.add_argument("timer", type=int, default=None, location="json") -slide_parser.add_argument("background_image_id", default=None, type=int, location="json") +slide_parser_edit = reqparse.RequestParser() +slide_parser_edit.add_argument("order", type=int, default=sentinel, location="json") +slide_parser_edit.add_argument("title", type=str, default=sentinel, location="json") +slide_parser_edit.add_argument("timer", type=int, default=sentinel, location="json") +slide_parser_edit.add_argument("background_image_id", default=sentinel, type=int, location="json") @api.route("") @@ -40,10 +41,10 @@ class Slides(Resource): @protect_route(allowed_roles=["*"]) def put(self, competition_id, slide_id): - args = slide_parser.parse_args(strict=True) + args = slide_parser_edit.parse_args(strict=True) item_slide = dbc.get.slide(competition_id, slide_id) - item_slide = dbc.edit.slide(item_slide, **args) + item_slide = dbc.edit.default(item_slide, **args) return item_response(schema.dump(item_slide)) @@ -60,7 +61,7 @@ class Slides(Resource): class SlideOrder(Resource): @protect_route(allowed_roles=["*"]) def put(self, competition_id, slide_id): - args = slide_parser.parse_args(strict=True) + args = slide_parser_edit.parse_args(strict=True) order = args.get("order") item_slide = dbc.get.slide(competition_id, slide_id) diff --git a/server/app/apis/teams.py b/server/app/apis/teams.py index c951ae53e6b7839d1457e60ccce60ea398ee617d..71ca715d55d21de75f07db4241e5ef0bb14e1050 100644 --- a/server/app/apis/teams.py +++ b/server/app/apis/teams.py @@ -3,14 +3,17 @@ import app.database.controller as dbc from app.apis import item_response, list_response, protect_route from app.core.dto import TeamDTO from flask_restx import Resource, reqparse -from flask_restx import reqparse +from app.core.parsers import sentinel api = TeamDTO.api schema = TeamDTO.schema list_schema = TeamDTO.list_schema -team_parser = reqparse.RequestParser() -team_parser.add_argument("name", type=str, location="json") +team_parser_add = reqparse.RequestParser() +team_parser_add.add_argument("name", type=str, required=True, location="json") + +team_parser_edit = reqparse.RequestParser() +team_parser_edit.add_argument("name", type=str, default=sentinel, location="json") @api.route("") @@ -23,7 +26,7 @@ class TeamsList(Resource): @protect_route(allowed_roles=["*"]) def post(self, competition_id): - args = team_parser.parse_args(strict=True) + args = team_parser_add.parse_args(strict=True) item_team = dbc.add.team(args["name"], competition_id) return item_response(schema.dump(item_team)) @@ -45,7 +48,7 @@ class Teams(Resource): @protect_route(allowed_roles=["*"]) def put(self, competition_id, team_id): - args = team_parser.parse_args(strict=True) + args = team_parser_edit.parse_args(strict=True) name = args.get("name") item_team = dbc.get.team(competition_id, team_id) diff --git a/server/app/apis/users.py b/server/app/apis/users.py index 50470db724ebf3648a2ff3a206a87b53650417ee..dc26ac5b64e9a4270215374de84f3c71cae66f9e 100644 --- a/server/app/apis/users.py +++ b/server/app/apis/users.py @@ -5,23 +5,23 @@ from app.core.dto import UserDTO from flask_jwt_extended import get_jwt_identity from flask_restx import Resource from flask_restx import inputs, reqparse -from app.core.parsers import search_parser +from app.core.parsers import search_parser, sentinel api = UserDTO.api schema = UserDTO.schema list_schema = UserDTO.list_schema -user_parser = reqparse.RequestParser() -user_parser.add_argument("email", type=inputs.email(), location="json") -user_parser.add_argument("name", type=str, location="json") -user_parser.add_argument("city_id", type=int, location="json") -user_parser.add_argument("role_id", type=int, location="json") +user_parser_edit = reqparse.RequestParser() +user_parser_edit.add_argument("email", type=inputs.email(), default=sentinel, location="json") +user_parser_edit.add_argument("name", type=str, default=sentinel, location="json") +user_parser_edit.add_argument("city_id", type=int, default=sentinel, location="json") +user_parser_edit.add_argument("role_id", type=int, default=sentinel, location="json") user_search_parser = search_parser.copy() -user_search_parser.add_argument("name", type=str, default=None, location="args") -user_search_parser.add_argument("email", type=str, default=None, location="args") -user_search_parser.add_argument("city_id", type=int, default=None, location="args") -user_search_parser.add_argument("role_id", type=int, default=None, location="args") +user_search_parser.add_argument("name", type=str, default=sentinel, location="args") +user_search_parser.add_argument("email", type=str, default=sentinel, location="args") +user_search_parser.add_argument("city_id", type=int, default=sentinel, location="args") +user_search_parser.add_argument("role_id", type=int, default=sentinel, location="args") def _edit_user(item_user, args): @@ -47,7 +47,7 @@ class UsersList(Resource): @protect_route(allowed_roles=["*"]) def put(self): - args = user_parser.parse_args(strict=True) + args = user_parser_edit.parse_args(strict=True) item = dbc.get.user(get_jwt_identity()) item = _edit_user(item, args) return item_response(schema.dump(item)) @@ -63,7 +63,7 @@ class Users(Resource): @protect_route(allowed_roles=["Admin"]) def put(self, ID): - args = user_parser.parse_args(strict=True) + args = user_parser_edit.parse_args(strict=True) item = dbc.get.user(ID) item = _edit_user(item, args) return item_response(schema.dump(item)) diff --git a/server/app/core/parsers.py b/server/app/core/parsers.py index c695dd230b208d95fff4eaba14f12c9732042376..160d67a0af7b58483202c00c1ec6b97097de0633 100644 --- a/server/app/core/parsers.py +++ b/server/app/core/parsers.py @@ -1,5 +1,22 @@ from flask_restx import inputs, reqparse + +class Sentinel: + """ + Sentinel is used as default argument to parsers if it isn't necessary to + supply a value. This is used instead of None so that None can be supplied + as value. + """ + + def __repr__(self): + return "Sentinel" + + def __bool__(self): + return False + + +sentinel = Sentinel() + ###SEARCH#### search_parser = reqparse.RequestParser() search_parser.add_argument("page", type=int, default=0, location="args") diff --git a/server/app/core/schemas.py b/server/app/core/schemas.py index 0abe02526ef28041e7c0722fc5aa0b0bc5e7f55d..4008b8869cc084022d4d46386d89ec29ca1765d2 100644 --- a/server/app/core/schemas.py +++ b/server/app/core/schemas.py @@ -161,6 +161,8 @@ class ComponentSchema(BaseSchema): h = ma.auto_field() slide_id = ma.auto_field() type_id = ma.auto_field() + view_type_id = ma.auto_field() text = fields.fields.String() media = fields.Nested(MediaSchema, many=False) + question_id = fields.fields.Integer() \ No newline at end of file diff --git a/server/app/core/sockets.py b/server/app/core/sockets.py index 66c40b627a6a88f03228c22410a7c4258745a0bd..280529fc102a29fc706b24ec68f268d324520d86 100644 --- a/server/app/core/sockets.py +++ b/server/app/core/sockets.py @@ -1,11 +1,12 @@ +import logging + import app.database.controller as dbc from app.core import db -from app.database.models import Competition, Slide, Team, ViewType, Code +from app.database.models import Code, Competition, Slide, Team, ViewType from flask.globals import request -from flask_socketio import SocketIO, emit, join_room -import logging from flask_jwt_extended import verify_jwt_in_request from flask_jwt_extended.utils import get_jwt_claims +from flask_socketio import SocketIO, emit, join_room logger = logging.getLogger(__name__) logger.propagate = False @@ -69,6 +70,7 @@ def disconnect(): logger.info(f"Client '{request.sid}' disconnected") +@protect_route(allowed_views=["Operator"]) @sio.on("start_presentation") def start_presentation(data): competition_id = data["competition_id"] @@ -91,6 +93,7 @@ def start_presentation(data): logger.info(f"Client '{request.sid}' started competition '{competition_id}'") +@protect_route(allowed_views=["Operator"]) @sio.on("end_presentation") def end_presentation(data): competition_id = data["competition_id"] @@ -155,8 +158,8 @@ def join_presentation(data): logger.info(f"Client '{request.sid}' joined competition '{competition_id}'") -@sio.on("set_slide") @protect_route(allowed_views=["Operator"]) +@sio.on("set_slide") def set_slide(data): competition_id = data["competition_id"] slide_order = data["slide_order"] @@ -195,6 +198,7 @@ def set_slide(data): logger.info(f"Client '{request.sid}' set slide '{slide_order}' in competition '{competition_id}'") +@protect_route(allowed_views=["Operator"]) @sio.on("set_timer") def set_timer(data): competition_id = data["competition_id"] diff --git a/server/app/database/controller/add.py b/server/app/database/controller/add.py index 57e41705cb06f500c8678510ac1ba0629eb9c0e7..6c5ea14bcd143a4bac5d0f214fc3d14f8f73bfe0 100644 --- a/server/app/database/controller/add.py +++ b/server/app/database/controller/add.py @@ -20,6 +20,7 @@ from app.database.models import ( Question, QuestionAlternative, QuestionAnswer, + QuestionComponent, QuestionType, Role, Slide, @@ -37,6 +38,8 @@ from sqlalchemy.orm import relation from sqlalchemy.orm.session import sessionmaker from flask import current_app +from app.database.types import ID_IMAGE_COMPONENT, ID_QUESTION_COMPONENT, ID_TEXT_COMPONENT + def db_add(item): """ @@ -59,7 +62,7 @@ def db_add(item): return item -def component(type_id, slide_id, x=0, y=0, w=0, h=0, **data): +def component(type_id, slide_id, view_type_id, x=0, y=0, w=0, h=0, **data): """ Adds a component to the slide at the specified coordinates with the provided size and data . @@ -79,12 +82,15 @@ def component(type_id, slide_id, x=0, y=0, w=0, h=0, **data): w *= ratio h *= ratio - if type_id == 1: - item = db_add(TextComponent(slide_id, type_id, x, y, w, h)) + if type_id == ID_TEXT_COMPONENT: + item = db_add(TextComponent(slide_id, type_id, view_type_id, x, y, w, h)) item.text = data.get("text") - elif type_id == 2: - item = db_add(ImageComponent(slide_id, type_id, x, y, w, h)) + elif type_id == ID_IMAGE_COMPONENT: + item = db_add(ImageComponent(slide_id, type_id, view_type_id, x, y, w, h)) item.media_id = data.get("media_id") + elif type_id == ID_QUESTION_COMPONENT: + item = db_add(QuestionComponent(slide_id, type_id, view_type_id, x, y, w, h)) + item.question_id = data.get("question_id") else: abort(codes.BAD_REQUEST, f"Invalid type_id{type_id}") diff --git a/server/app/database/controller/copy.py b/server/app/database/controller/copy.py index 7e9d28d02381dfe36e2a0c7792745618b595588e..a32ba1deda39c231bfa7ea2b26f91337e0ee2bec 100644 --- a/server/app/database/controller/copy.py +++ b/server/app/database/controller/copy.py @@ -4,6 +4,7 @@ This file contains functionality to copy and duplicate data to the database. from app.database.controller import add, get, search, utils from app.database.models import Question +from app.database.types import ID_IMAGE_COMPONENT, ID_QUESTION_COMPONENT, ID_TEXT_COMPONENT def _alternative(item_old, question_id): @@ -38,13 +39,16 @@ def _component(item_component, item_slide_new): component item to the specified slide. """ data = {} - if item_component.type_id == 1: + if item_component.type_id == ID_TEXT_COMPONENT: data["text"] = item_component.text - elif item_component.type_id == 2: + elif item_component.type_id == ID_IMAGE_COMPONENT: data["media_id"] = item_component.media_id + elif item_component.type_id == ID_QUESTION_COMPONENT: + data["question_id"] = item_component.question_id add.component( item_component.type_id, item_slide_new.id, + item_component.view_type_id, item_component.x, item_component.y, item_component.w, diff --git a/server/app/database/controller/edit.py b/server/app/database/controller/edit.py index 94bc6008a1f5b8173df1ddbba7d6745f7c6ea5f0..efb4909696cf2ae911a2451e1427b60b460f1153 100644 --- a/server/app/database/controller/edit.py +++ b/server/app/database/controller/edit.py @@ -3,6 +3,7 @@ This file contains functionality to get data from the database. """ from app.core import db +from app.core.parsers import sentinel def switch_order(item1, item2): @@ -46,22 +47,8 @@ def default(item, **kwargs): for key, value in kwargs.items(): if not hasattr(item, key): raise AttributeError(f"Item of type {type(item)} has no attribute '{key}'") - if value is not None: + if value is not sentinel: setattr(item, key, value) db.session.commit() db.session.refresh(item) return item - - -def competition(item, **kwargs): - if kwargs["background_image_id"] == -1: - item.background_image_id = None - del kwargs["background_image_id"] - return default(item, **kwargs) - - -def slide(item, **kwargs): - if kwargs["background_image_id"] == -1: - item.background_image_id = None - del kwargs["background_image_id"] - return default(item, **kwargs) \ No newline at end of file diff --git a/server/app/database/models.py b/server/app/database/models.py index b697b830cf2a8c4d55d3fb69a5be56ceececa50e..f9f18438ad07edf951cebd24ad7ab92e9b854299 100644 --- a/server/app/database/models.py +++ b/server/app/database/models.py @@ -1,7 +1,7 @@ from app.core import bcrypt, db from sqlalchemy.ext.hybrid import hybrid_method, hybrid_property -from app.database.types import ID_IMAGE_COMPONENT, ID_TEXT_COMPONENT +from app.database.types import ID_IMAGE_COMPONENT, ID_QUESTION_COMPONENT, ID_TEXT_COMPONENT STRING_SIZE = 254 @@ -199,13 +199,14 @@ class Component(db.Model): __mapper_args__ = {"polymorphic_on": type_id} - def __init__(self, slide_id, type_id, x=0, y=0, w=1, h=1): + def __init__(self, slide_id, type_id, view_type_id, x=0, y=0, w=1, h=1): self.x = x self.y = y self.w = w self.h = h self.slide_id = slide_id self.type_id = type_id + self.view_type_id = view_type_id class TextComponent(Component): @@ -223,6 +224,13 @@ class ImageComponent(Component): __mapper_args__ = {"polymorphic_identity": ID_IMAGE_COMPONENT} +class QuestionComponent(Component): + question_id = db.Column(db.Integer, db.ForeignKey("question.id"), nullable=True) + + # __tablename__ = None + __mapper_args__ = {"polymorphic_identity": ID_QUESTION_COMPONENT} + + class Code(db.Model): id = db.Column(db.Integer, primary_key=True) code = db.Column(db.Text, unique=True) diff --git a/server/app/database/types.py b/server/app/database/types.py index 236e50f5278d03684f2cbdcc05c2e7637e21a057..f53835eccf10599eb9ab81d8af1426e02a21e405 100644 --- a/server/app/database/types.py +++ b/server/app/database/types.py @@ -1,2 +1,3 @@ ID_TEXT_COMPONENT = 1 ID_IMAGE_COMPONENT = 2 +ID_QUESTION_COMPONENT = 3 \ No newline at end of file diff --git a/server/populate.py b/server/populate.py index 6a4be4ef42cbe14d938466282c6db2addb92dc57..e0bb0bd94faf1b210069d7507d48e3f740157209 100644 --- a/server/populate.py +++ b/server/populate.py @@ -86,7 +86,13 @@ def _add_items(): y = random.randrange(1, 500) w = random.randrange(150, 400) h = random.randrange(150, 400) - dbc.add.component(1, item_slide.id, x, y, w, h, text=f"hej{k}") + dbc.add.component(1, item_slide.id, 1, x, y, w, h, text=f"hej{k}") + for k in range(3): + x = random.randrange(1, 500) + y = random.randrange(1, 500) + w = random.randrange(150, 400) + h = random.randrange(150, 400) + dbc.add.component(1, item_slide.id, 3, x, y, w, h, text=f"hej{k}") # item_slide = dbc.add.slide(item_comp) # item_slide.title = f"Slide {len(item_comp.slides)}" diff --git a/server/tests/test_helpers.py b/server/tests/test_helpers.py index 5dabdbbe5508538291ce9eb52e078c21c8995132..f55aa68251a5a3b9ea73411c959939acc93f1bc6 100644 --- a/server/tests/test_helpers.py +++ b/server/tests/test_helpers.py @@ -73,7 +73,7 @@ def add_default_values(): # dbc.add.question(name=f"Q{i+1}", total_score=i + 1, type_id=1, slide_id=item_slide.id) # Add text component - dbc.add.component(1, item_slide.id, i, 2 * i, 3 * i, 4 * i, text="Text") + dbc.add.component(1, item_slide.id, 1, i, 2 * i, 3 * i, 4 * i, text="Text") def get_body(response):