diff --git a/.env b/.env index f3793fa..3ba890f 100644 --- a/.env +++ b/.env @@ -1,3 +1,8 @@ REACT_APP_COMPILER_URL=https://compiler.sensebox.de REACT_APP_BOARD=sensebox-mcu REACT_APP_BLOCKLY_API=https://api.blockly.sensebox.de + +REACT_APP_MYBADGES=https://mybadges.org + +# in days +REACT_APP_SHARE_LINK_EXPIRES=30 diff --git a/package-lock.json b/package-lock.json index 2382348..1a11fa6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2581,6 +2581,14 @@ "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.10.0.tgz", "integrity": "sha512-3YDiu347mtVtjpyV3u5kVqQLP242c06zwDOgpeRnybmXlYYsLbtTrUBUm8i8srONt+FWobl5aibnU1030PeeuA==" }, + "axios": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.0.tgz", + "integrity": "sha512-fmkJBknJKoZwem3/IKSSLpkdNXZeBu5Q7GA/aRsr2btgrptmSCxi2oFjZHqGdK9DoTil9PIHlPIZw2EcRJXRvw==", + "requires": { + "follow-redirects": "^1.10.0" + } + }, "axobject-query": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz", diff --git a/package.json b/package.json index 44ff8d2..60a336a 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ }, "scripts": { "start": "react-scripts start", + "dev": "set \"REACT_APP_BLOCKLY_API=http://localhost:8080\" && npm start", "build": "react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject" diff --git a/src/App.js b/src/App.js index cf38679..1eccac4 100644 --- a/src/App.js +++ b/src/App.js @@ -1,10 +1,11 @@ -import React from 'react'; +import React, { Component } from 'react'; import { BrowserRouter as Router } from 'react-router-dom'; import { createBrowserHistory } from "history"; import { Provider } from 'react-redux'; import store from './store'; +import { loadUser } from './actions/authActions'; import './App.css'; @@ -12,7 +13,7 @@ import { ThemeProvider, createMuiTheme } from '@material-ui/core/styles'; import Navbar from './components/Navbar'; import Footer from './components/Footer'; -import Routes from './components/Routes'; +import Routes from './components/Route/Routes'; import Cookies from './components/Cookies'; const theme = createMuiTheme({ @@ -23,28 +24,36 @@ const theme = createMuiTheme({ }, secondary: { main: '#DDDDDD' + }, + button: { + compile: '#e27136' } } }); -const customHistory = createBrowserHistory(); +class App extends Component { + componentDidMount() { + store.dispatch(loadUser()); + } -function App() { - return ( - - - -
- - - -
-
-
-
- ); + render() { + const customHistory = createBrowserHistory(); + return ( + + + +
+ + + +
+
+
+
+ ); + } } export default App; diff --git a/src/actions/authActions.js b/src/actions/authActions.js new file mode 100644 index 0000000..7cdd1c0 --- /dev/null +++ b/src/actions/authActions.js @@ -0,0 +1,233 @@ +import { MYBADGES_CONNECT, MYBADGES_DISCONNECT, USER_LOADED, USER_LOADING, AUTH_ERROR, LOGIN_SUCCESS, LOGIN_FAIL, LOGOUT_SUCCESS, LOGOUT_FAIL, REFRESH_TOKEN_SUCCESS } from '../actions/types'; + +import axios from 'axios'; +import { returnErrors, returnSuccess } from './messageActions' + + +// Check token & load user +export const loadUser = () => (dispatch) => { + // user loading + dispatch({ + type: USER_LOADING + }); + const config = { + success: res => { + dispatch({ + type: USER_LOADED, + payload: res.data.user + }); + }, + error: err => { + if(err.response){ + dispatch(returnErrors(err.response.data.message, err.response.status)); + } + dispatch({ + type: AUTH_ERROR + }); + } + }; + axios.get(`${process.env.REACT_APP_BLOCKLY_API}/user`, config, dispatch(authInterceptor())) + .then(res => { + res.config.success(res); + }) + .catch(err => { + err.config.error(err); + }); +}; + + +var logoutTimerId; +const timeToLogout = 14.9*60*1000; // nearly 15 minutes corresponding to the API + +// Login user +export const login = ({ email, password }) => (dispatch) => { + // Headers + const config = { + headers: { + 'Content-Type': 'application/json' + } + }; + // Request Body + const body = JSON.stringify({ email, password }); + axios.post(`${process.env.REACT_APP_BLOCKLY_API}/user`, body, config) + .then(res => { + // Logout automatically if refreshToken "expired" + const logoutTimer = () => setTimeout( + () => dispatch(logout()), + timeToLogout + ); + logoutTimerId = logoutTimer(); + dispatch({ + type: LOGIN_SUCCESS, + payload: res.data + }); + dispatch(returnSuccess(res.data.message, res.status, 'LOGIN_SUCCESS')); + }) + .catch(err => { + dispatch(returnErrors(err.response.data.message, err.response.status, 'LOGIN_FAIL')); + dispatch({ + type: LOGIN_FAIL + }); + }); +}; + + +// Connect to MyBadges-Account +export const connectMyBadges = ({ username, password }) => (dispatch, getState) => { + // Headers + const config = { + headers: { + 'Content-Type': 'application/json' + } + }; + // Request Body + const body = JSON.stringify({ username, password }); + axios.post(`${process.env.REACT_APP_BLOCKLY_API}/user/badge`, body, config) + .then(res => { + var user = getState().auth.user; + user.badge = res.data.account; + user.badges = res.data.badges; + dispatch({ + type: MYBADGES_CONNECT, + payload: user + }); + dispatch(returnSuccess(res.data.message, res.status, 'MYBADGES_CONNECT_SUCCESS')); + }) + .catch(err => { + dispatch(returnErrors(err.response.data.message, err.response.status, 'MYBADGES_CONNECT_FAIL')); + }); +}; + +// Disconnect MyBadges-Account +export const disconnectMyBadges = () => (dispatch, getState) => { + // Headers + const config = { + headers: { + 'Content-Type': 'application/json' + } + }; + axios.put(`${process.env.REACT_APP_BLOCKLY_API}/user/badge`, config) + .then(res => { + var user = getState().auth.user; + user.badge = null; + user.badges = null; + dispatch({ + type: MYBADGES_DISCONNECT, + payload: user + }); + dispatch(returnSuccess(res.data.message, res.status, 'MYBADGES_DISCONNECT_SUCCESS')); + }) + .catch(err => { + dispatch(returnErrors(err.response.data.message, err.response.status, 'MYBADGES_DISCONNECT_FAIL')); + }); +}; + + +// Logout User +export const logout = () => (dispatch) => { + const config = { + success: res => { + dispatch({ + type: LOGOUT_SUCCESS + }); + dispatch(returnSuccess(res.data.message, res.status, 'LOGOUT_SUCCESS')); + clearTimeout(logoutTimerId); + }, + error: err => { + dispatch(returnErrors(err.response.data.message, err.response.status, 'LOGOUT_FAIL')); + dispatch({ + type: LOGOUT_FAIL + }); + clearTimeout(logoutTimerId); + } + }; + axios.post('https://api.opensensemap.org/users/sign-out', {}, config) + .then(res => { + res.config.success(res); + }) + .catch(err => { + if(err.response.status !== 401){ + err.config.error(err); + } + }); +}; + + +export const authInterceptor = () => (dispatch, getState) => { + // Add a request interceptor + axios.interceptors.request.use( + config => { + config.headers['Content-Type'] = 'application/json'; + const token = getState().auth.token; + if (token) { + config.headers['Authorization'] = `Bearer ${token}`; + } + return config; + }, + error => { + Promise.reject(error); + } + ); + + // Add a response interceptor + axios.interceptors.response.use( + response => { + // request was successfull + return response; + }, + error => { + const originalRequest = error.config; + const refreshToken = getState().auth.refreshToken; + if(refreshToken){ + // try to refresh the token failed + if (error.response.status === 401 && originalRequest._retry) { + // router.push('/login'); + return Promise.reject(error); + } + // token was not valid and 1st try to refresh the token + if (error.response.status === 401 && !originalRequest._retry) { + originalRequest._retry = true; + const refreshToken = getState().auth.refreshToken; + // request to refresh the token, in request-body is the refreshToken + axios.post('https://api.opensensemap.org/users/refresh-auth', {"token": refreshToken}) + .then(res => { + if (res.status === 200) { + clearTimeout(logoutTimerId); + const logoutTimer = () => setTimeout( + () => dispatch(logout()), + timeToLogout + ); + logoutTimerId = logoutTimer(); + dispatch({ + type: REFRESH_TOKEN_SUCCESS, + payload: res.data + }); + axios.defaults.headers.common['Authorization'] = 'Bearer ' + getState().auth.token; + // request was successfull, new request with the old parameters and the refreshed token + return axios(originalRequest) + .then(res => { + originalRequest.success(res); + }) + .catch(err => { + originalRequest.error(err); + }); + } + return Promise.reject(error); + }) + .catch(err => { + // request failed, token could not be refreshed + if(err.response){ + dispatch(returnErrors(err.response.data.message, err.response.status)); + } + dispatch({ + type: AUTH_ERROR + }); + return Promise.reject(error); + }); + } + } + // request status was unequal to 401, no possibility to refresh the token + return Promise.reject(error); + } + ); +}; diff --git a/src/actions/projectActions.js b/src/actions/projectActions.js new file mode 100644 index 0000000..8ab01cf --- /dev/null +++ b/src/actions/projectActions.js @@ -0,0 +1,174 @@ +import { PROJECT_PROGRESS, GET_PROJECT, GET_PROJECTS, PROJECT_TYPE, PROJECT_DESCRIPTION } from './types'; + +import axios from 'axios'; +import { workspaceName } from './workspaceActions'; +import { returnErrors, returnSuccess } from './messageActions'; + +export const setType = (type) => (dispatch) => { + dispatch({ + type: PROJECT_TYPE, + payload: type + }); +}; + +export const setDescription = (description) => (dispatch) => { + dispatch({ + type: PROJECT_DESCRIPTION, + payload: description + }); +}; + +export const getProject = (type, id) => (dispatch) => { + dispatch({type: PROJECT_PROGRESS}); + dispatch(setType(type)); + axios.get(`${process.env.REACT_APP_BLOCKLY_API}/${type}/${id}`) + .then(res => { + var data = type === 'share' ? 'content' : type; + var project = res.data[data]; + if(project){ + dispatch({ + type: GET_PROJECT, + payload: project + }); + dispatch({ + type: PROJECT_DESCRIPTION, + payload: project.description + }); + dispatch({type: PROJECT_PROGRESS}); + dispatch(returnSuccess(res.data.message, res.status, 'GET_PROJECT_SUCCESS')); + } + else{ + dispatch({type: PROJECT_PROGRESS}); + dispatch(returnErrors(res.data.message, res.status, 'PROJECT_EMPTY')); + } + }) + .catch(err => { + if(err.response){ + dispatch(returnErrors(err.response.data.message, err.response.status, 'GET_PROJECT_FAIL')); + } + dispatch({type: PROJECT_PROGRESS}); + }); +}; + +export const getProjects = (type) => (dispatch) => { + dispatch({type: PROJECT_PROGRESS}); + axios.get(`${process.env.REACT_APP_BLOCKLY_API}/${type}`) + .then(res => { + var data = type === 'project' ? 'projects' : 'galleries'; + var projects = res.data[data]; + dispatch({ + type: GET_PROJECTS, + payload: projects + }); + dispatch({type: PROJECT_PROGRESS}); + dispatch(returnSuccess(res.data.message, res.status)); + }) + .catch(err => { + if(err.response){ + dispatch(returnErrors(err.response.data.message, err.response.status, 'GET_PROJECTS_FAIL')); + } + dispatch({type: PROJECT_PROGRESS}); + }); +}; + +export const updateProject = (type, id) => (dispatch, getState) => { + var workspace = getState().workspace; + var body = { + xml: workspace.code.xml, + title: workspace.name + }; + var project = getState().project; + if(type==='gallery'){ + body.description = project.description; + } + axios.put(`${process.env.REACT_APP_BLOCKLY_API}/${type}/${id}`, body) + .then(res => { + var project = res.data[type]; + var projects = getState().project.projects; + var index = projects.findIndex(res => res._id === project._id); + projects[index] = project; + dispatch({ + type: GET_PROJECTS, + payload: projects + }); + if(type === 'project'){ + dispatch(returnSuccess(res.data.message, res.status, 'PROJECT_UPDATE_SUCCESS')); + } else { + dispatch(returnSuccess(res.data.message, res.status, 'GALLERY_UPDATE_SUCCESS')); + } + }) + .catch(err => { + if(err.response){ + if(type === 'project'){ + dispatch(returnErrors(err.response.data.message, err.response.status, 'PROJECT_UPDATE_FAIL')); + } else { + dispatch(returnErrors(err.response.data.message, err.response.status, 'GALLERY_UPDATE_FAIL')); + } + } + }); +}; + +export const deleteProject = (type, id) => (dispatch, getState) => { + var project = getState().project; + axios.delete(`${process.env.REACT_APP_BLOCKLY_API}/${type}/${id}`) + .then(res => { + var projects = getState().project.projects; + var index = projects.findIndex(res => res._id === id); + projects.splice(index, 1) + dispatch({ + type: GET_PROJECTS, + payload: projects + }); + if(type === 'project'){ + dispatch(returnSuccess(res.data.message, res.status, 'PROJECT_DELETE_SUCCESS')); + } else { + dispatch(returnSuccess(res.data.message, res.status, 'GALLERY_DELETE_SUCCESS')); + } + }) + .catch(err => { + if(err.response){ + dispatch(returnErrors(err.response.data.message, err.response.status, 'PROJECT_DELETE_FAIL')); + } + }); +}; + + +export const shareProject = (title, type, id) => (dispatch, getState) => { + var body = { + title: title + }; + if(type === 'project'){ + body.projectId = id; + } else { + body.xml = getState().workspace.code.xml; + } + axios.post(`${process.env.REACT_APP_BLOCKLY_API}/share`, body) + .then(res => { + var shareContent = res.data.content; + if(body.projectId){ + var projects = getState().project.projects; + var index = projects.findIndex(res => res._id === id); + projects[index].shared = shareContent.expiresAt; + dispatch({ + type: GET_PROJECTS, + payload: projects + }); + } + dispatch(returnSuccess(res.data.message, shareContent._id, 'SHARE_SUCCESS')); + }) + .catch(err => { + if(err.response){ + dispatch(returnErrors(err.response.data.message, err.response.status, 'SHARE_FAIL')); + } + }); +}; + + +export const resetProject = () => (dispatch) => { + dispatch({ + type: GET_PROJECTS, + payload: [] + }); + dispatch(setType('')); + dispatch(setDescription('')); +}; diff --git a/src/actions/tutorialActions.js b/src/actions/tutorialActions.js index 0358d5f..8983da9 100644 --- a/src/actions/tutorialActions.js +++ b/src/actions/tutorialActions.js @@ -1,13 +1,13 @@ -import { TUTORIAL_PROGRESS, GET_TUTORIAL, GET_TUTORIALS, TUTORIAL_SUCCESS, TUTORIAL_ERROR, TUTORIAL_CHANGE, TUTORIAL_XML, TUTORIAL_ID, TUTORIAL_STEP } from './types'; +import { MYBADGES_DISCONNECT, TUTORIAL_PROGRESS, GET_TUTORIAL, GET_TUTORIALS, TUTORIAL_SUCCESS, TUTORIAL_ERROR, TUTORIAL_CHANGE, TUTORIAL_XML, TUTORIAL_ID, TUTORIAL_STEP } from './types'; import axios from 'axios'; import { returnErrors, returnSuccess } from './messageActions'; export const getTutorial = (id) => (dispatch, getState) => { dispatch({type: TUTORIAL_PROGRESS}); - axios.get(`https://api.blockly.sensebox.de/tutorial/${id}`) + axios.get(`${process.env.REACT_APP_BLOCKLY_API}/tutorial/${id}`) .then(res => { - var tutorial = res.data; + var tutorial = res.data.tutorial; existingTutorial(tutorial, getState().tutorial.status).then(status => { dispatch({ type: TUTORIAL_SUCCESS, @@ -31,9 +31,10 @@ export const getTutorial = (id) => (dispatch, getState) => { export const getTutorials = () => (dispatch, getState) => { dispatch({type: TUTORIAL_PROGRESS}); - axios.get(`https://api.blockly.sensebox.de/tutorial`) + axios.get(`${process.env.REACT_APP_BLOCKLY_API}/tutorial`) .then(res => { - var tutorials = res.data; + var tutorials = res.data.tutorials; + console.log(tutorials); existingTutorials(tutorials, getState().tutorial.status).then(status => { dispatch({ type: TUTORIAL_SUCCESS, @@ -55,6 +56,47 @@ export const getTutorials = () => (dispatch, getState) => { }); }; +export const assigneBadge = (id) => (dispatch, getState) => { + axios.put(`${process.env.REACT_APP_BLOCKLY_API}/user/badge/${id}`) + .then(res => { + var badge = res.data.badge; + var user = getState().auth.user; + user.badges.push(badge._id); + dispatch({ + type: MYBADGES_DISCONNECT, + payload: user + }); + dispatch(returnSuccess(badge, res.status, 'ASSIGNE_BADGE_SUCCESS')); + }) + .catch(err => { + if(err.response){ + dispatch(returnErrors(err.response.data.message, err.response.status, 'ASSIGNE_BADGE_FAIL')); + } + }); +}; + +export const deleteTutorial = (id) => (dispatch, getState) => { + var tutorial = getState().tutorial; + var id = getState().builder.id; + axios.delete(`${process.env.REACT_APP_BLOCKLY_API}/tutorial/${id}`) + .then(res => { + var tutorials = tutorial.tutorials; + var index = tutorials.findIndex(res => res._id === id); + tutorials.splice(index, 1) + dispatch({ + type: GET_TUTORIALS, + payload: tutorials + }); + dispatch(returnSuccess(res.data.message, res.status, 'TUTORIAL_DELETE_SUCCESS')); + }) + .catch(err => { + if(err.response){ + dispatch(returnErrors(err.response.data.message, err.response.status, 'TUTORIAL_DELETE_FAIL')); + } + }); +}; + + export const resetTutorial = () => (dispatch) => { dispatch({ type: GET_TUTORIALS, @@ -74,9 +116,9 @@ export const tutorialChange = () => (dispatch) => { export const tutorialCheck = (status, step) => (dispatch, getState) => { var tutorialsStatus = getState().tutorial.status; - var id = getState().tutorial.tutorials[0].id; - var tutorialsStatusIndex = tutorialsStatus.findIndex(tutorialStatus => tutorialStatus.id === id); - var tasksIndex = tutorialsStatus[tutorialsStatusIndex].tasks.findIndex(task => task.id === step.id); + var id = getState().tutorial.tutorials[0]._id; + var tutorialsStatusIndex = tutorialsStatus.findIndex(tutorialStatus => tutorialStatus._id === id); + var tasksIndex = tutorialsStatus[tutorialsStatusIndex].tasks.findIndex(task => task._id === step._id); tutorialsStatus[tutorialsStatusIndex].tasks[tasksIndex] = { ...tutorialsStatus[tutorialsStatusIndex].tasks[tasksIndex], type: status @@ -86,18 +128,19 @@ export const tutorialCheck = (status, step) => (dispatch, getState) => { payload: tutorialsStatus }); dispatch(tutorialChange()); + dispatch(returnSuccess('','','TUTORIAL_CHECK_SUCCESS')); }; export const storeTutorialXml = (code) => (dispatch, getState) => { var tutorial = getState().tutorial.tutorials[0]; if (tutorial) { - var id = tutorial.id; + var id = tutorial._id; var activeStep = getState().tutorial.activeStep; var steps = tutorial.steps; if (steps && steps[activeStep].type === 'task') { var tutorialsStatus = getState().tutorial.status; - var tutorialsStatusIndex = tutorialsStatus.findIndex(tutorialStatus => tutorialStatus.id === id); - var tasksIndex = tutorialsStatus[tutorialsStatusIndex].tasks.findIndex(task => task.id === steps[activeStep].id); + var tutorialsStatusIndex = tutorialsStatus.findIndex(tutorialStatus => tutorialStatus._id === id); + var tasksIndex = tutorialsStatus[tutorialsStatusIndex].tasks.findIndex(task => task._id === steps[activeStep]._id); tutorialsStatus[tutorialsStatusIndex].tasks[tasksIndex] = { ...tutorialsStatus[tutorialsStatusIndex].tasks[tasksIndex], xml: code @@ -125,38 +168,38 @@ const existingTutorials = (tutorials, status) => new Promise(function(resolve, r existingTutorial(tutorial, status).then(status => { newstatus = status; }); - return tutorial.id; + return tutorial._id; }); resolve(existingTutorialIds) }).then(existingTutorialIds => { // deleting old tutorials which do not longer exist if (existingTutorialIds.length > 0) { - status = newstatus.filter(status => existingTutorialIds.indexOf(status.id) > -1); + status = newstatus.filter(status => existingTutorialIds.indexOf(status._id) > -1); } resolve(status); }); }); const existingTutorial = (tutorial, status) => new Promise(function(resolve, reject){ - var tutorialsId = tutorial.id; - var statusIndex = status.findIndex(status => status.id === tutorialsId); + var tutorialsId = tutorial._id; + var statusIndex = status.findIndex(status => status._id === tutorialsId); if (statusIndex > -1) { var tasks = tutorial.steps.filter(step => step.type === 'task'); var existingTaskIds = tasks.map((task, j) => { - var tasksId = task.id; - if (status[statusIndex].tasks.findIndex(task => task.id === tasksId) === -1) { + var tasksId = task._id; + if (status[statusIndex].tasks.findIndex(task => task._id === tasksId) === -1) { // task does not exist - status[statusIndex].tasks.push({ id: tasksId }); + status[statusIndex].tasks.push({ _id: tasksId }); } return tasksId; }); // deleting old tasks which do not longer exist if (existingTaskIds.length > 0) { - status[statusIndex].tasks = status[statusIndex].tasks.filter(task => existingTaskIds.indexOf(task.id) > -1); + status[statusIndex].tasks = status[statusIndex].tasks.filter(task => existingTaskIds.indexOf(task._id) > -1); } } else { - status.push({ id: tutorialsId, tasks: tutorial.steps.filter(step => step.type === 'task').map(task => { return { id: task.id }; }) }); + status.push({ _id: tutorialsId, tasks: tutorial.steps.filter(step => step.type === 'task').map(task => { return { _id: task._id }; }) }); } resolve(status); }); diff --git a/src/actions/tutorialBuilderActions.js b/src/actions/tutorialBuilderActions.js index 328c7fa..356e8fa 100644 --- a/src/actions/tutorialBuilderActions.js +++ b/src/actions/tutorialBuilderActions.js @@ -1,4 +1,4 @@ -import { PROGRESS, JSON_STRING, BUILDER_CHANGE, BUILDER_ERROR, BUILDER_TITLE, BUILDER_ID, BUILDER_ADD_STEP, BUILDER_DELETE_STEP, BUILDER_CHANGE_STEP, BUILDER_CHANGE_ORDER, BUILDER_DELETE_PROPERTY } from './types'; +import { PROGRESS, JSON_STRING, BUILDER_CHANGE, BUILDER_ERROR, BUILDER_TITLE, BUILDER_ID, BUILDER_BADGE, BUILDER_ADD_STEP, BUILDER_DELETE_STEP, BUILDER_CHANGE_STEP, BUILDER_CHANGE_ORDER, BUILDER_DELETE_PROPERTY } from './types'; import data from '../data/hardware.json'; @@ -39,6 +39,14 @@ export const tutorialId = (id) => (dispatch) => { dispatch(changeTutorialBuilder()); }; +export const tutorialBadge = (badge) => (dispatch) => { + dispatch({ + type: BUILDER_BADGE, + payload: badge + }); + dispatch(changeTutorialBuilder()); +}; + export const addStep = (index) => (dispatch, getState) => { var steps = getState().builder.steps; var step = { @@ -180,7 +188,7 @@ export const setSubmitError = () => (dispatch, getState) => { // if(builder.id === undefined || builder.id === ''){ // dispatch(setError(undefined, 'id')); // } - if (builder.id === undefined || builder.title === '') { + if (builder.title === '') { dispatch(setError(undefined, 'title')); } var type = builder.steps.map((step, i) => { @@ -189,7 +197,7 @@ export const setSubmitError = () => (dispatch, getState) => { step.id = i + 1; if (i === 0) { if (step.requirements && step.requirements.length > 0) { - var requirements = step.requirements.filter(requirement => typeof (requirement) === 'number'); + var requirements = step.requirements.filter(requirement => /^[0-9a-fA-F]{24}$/.test(requirement)); if (requirements.length < step.requirements.length) { dispatch(changeContent(requirements, i, 'requirements')); } @@ -243,7 +251,7 @@ export const progress = (inProgress) => (dispatch) => { export const resetTutorial = () => (dispatch, getState) => { dispatch(jsonString('')); dispatch(tutorialTitle('')); - dispatch(tutorialId('')); + dispatch(tutorialBadge('')); var steps = [ { id: 1, @@ -274,7 +282,7 @@ export const readJSON = (json) => (dispatch, getState) => { // accept only valid attributes var steps = json.steps.map((step, i) => { var object = { - id: step.id, + // id: step.id, type: step.type, headline: step.headline, text: step.text @@ -298,7 +306,7 @@ export const readJSON = (json) => (dispatch, getState) => { return object; }); dispatch(tutorialTitle(json.title)); - dispatch(tutorialId(json.id)); + dispatch(tutorialBadge(json.badge)); dispatch(tutorialSteps(steps)); dispatch(setSubmitError()); dispatch(progress(false)); diff --git a/src/actions/types.js b/src/actions/types.js index 800b868..b3bff75 100644 --- a/src/actions/types.js +++ b/src/actions/types.js @@ -1,3 +1,16 @@ +// authentication +export const USER_LOADING = 'USER_LOADING'; +export const USER_LOADED = 'USER_LOADED'; +export const AUTH_ERROR = 'AUTH_ERROR'; +export const LOGIN_SUCCESS = 'LOGIN_SUCCESS'; +export const LOGIN_FAIL = 'LOGIN_FAIL'; +export const LOGOUT_SUCCESS = 'LOGOUT_SUCCESS'; +export const LOGOUT_FAIL = 'LOGOUT_FAIL'; +export const REFRESH_TOKEN_FAIL = 'REFRESH_TOKEN_FAIL'; +export const REFRESH_TOKEN_SUCCESS = 'REFRESH_TOKEN_SUCCESS'; +export const MYBADGES_CONNECT = 'MYBADGES_CONNECT'; +export const MYBADGES_DISCONNECT = 'MYBADGES_DISCONNECT'; + export const NEW_CODE = 'NEW_CODE'; export const CHANGE_WORKSPACE = 'CHANGE_WORKSPACE'; export const CREATE_BLOCK = 'CREATE_BLOCK'; @@ -21,6 +34,7 @@ export const JSON_STRING = 'JSON_STRING'; export const BUILDER_CHANGE = 'BUILDER_CHANGE'; export const BUILDER_TITLE = 'BUILDER_TITLE'; +export const BUILDER_BADGE = 'BUILDER_BADGE'; export const BUILDER_ID = 'BUILDER_ID'; export const BUILDER_ADD_STEP = 'BUILDER_ADD_STEP'; export const BUILDER_DELETE_STEP = 'BUILDER_DELETE_STEP'; @@ -37,3 +51,11 @@ export const VISIT = 'VISIT'; export const GET_ERRORS = 'GET_ERRORS'; export const GET_SUCCESS = 'GET_SUCCESS'; export const CLEAR_MESSAGES = 'CLEAR_MESSAGES'; + + +// projects: share, gallery, project +export const PROJECT_PROGRESS = 'PROJECT_PROGRESS'; +export const GET_PROJECT = 'GET_PROJECT'; +export const GET_PROJECTS = 'GET_PROJECTS'; +export const PROJECT_TYPE = 'PROJECT_TYPE'; +export const PROJECT_DESCRIPTION = 'PROJECT_DESCRIPTION'; diff --git a/src/components/Alert.js b/src/components/Alert.js new file mode 100644 index 0000000..ce10c87 --- /dev/null +++ b/src/components/Alert.js @@ -0,0 +1,34 @@ +import React, { Component } from 'react'; + +import { withStyles } from '@material-ui/core/styles'; +import { fade } from '@material-ui/core/styles/colorManipulator'; + +import Typography from '@material-ui/core/Typography'; + +const styles = (theme) => ({ + alert: { + marginBottom: '20px', + border: `1px solid ${theme.palette.primary.main}`, + padding: '10px 20px', + borderRadius: '4px', + background: fade(theme.palette.primary.main, 0.3), + color: 'rgb(70,70,70)' + } +}); + + +export class Alert extends Component { + + render(){ + return( +
+ + {this.props.children} + +
+ ); + } +} + + +export default withStyles(styles, { withTheme: true })(Alert); diff --git a/src/components/Blockly/BlocklySvg.js b/src/components/Blockly/BlocklySvg.js index 84a4c00..739b052 100644 --- a/src/components/Blockly/BlocklySvg.js +++ b/src/components/Blockly/BlocklySvg.js @@ -53,7 +53,6 @@ class BlocklySvg extends Component { var css = ''; var bbox = document.getElementsByClassName("blocklyBlockCanvas")[0].getBBox(); var content = new XMLSerializer().serializeToString(canvas); - var xml = ` ${css}">${content}`; @@ -65,7 +64,7 @@ class BlocklySvg extends Component { render() { return (
); diff --git a/src/components/Blockly/blocks/sensebox-osem.js b/src/components/Blockly/blocks/sensebox-osem.js index ec8dd5a..144d00d 100644 --- a/src/components/Blockly/blocks/sensebox-osem.js +++ b/src/components/Blockly/blocks/sensebox-osem.js @@ -1,7 +1,14 @@ import * as Blockly from 'blockly/core'; import { getColour } from '../helpers/colour'; -var apiData = '[{"_id":"5e6073fe57703e001bb99453","createdAt":"2020-03-05T03:37:34.151Z","updatedAt":"2020-10-17T10:49:51.636Z","name":"Vtuzgorodok","currentLocation":{"timestamp":"2020-03-05T03:37:34.146Z","coordinates":[60.658676,56.833041,51],"type":"Point"},"exposure":"outdoor","sensors":[{"title":"PM10","unit":"µg/m³","sensorType":"SDS 011","icon":"osem-cloud","_id":"5e6073fe57703e001bb99458","lastMeasurement":{"value":"3.30","createdAt":"2020-10-17T10:49:51.627Z"}},{"title":"PM2.5","unit":"µg/m³","sensorType":"SDS 011","icon":"osem-cloud","_id":"5e6073fe57703e001bb99457","lastMeasurement":{"value":"0.90","createdAt":"2020-10-17T10:49:51.627Z"}},{"title":"Temperatur","unit":"°C","sensorType":"BME280","icon":"osem-thermometer","_id":"5e6073fe57703e001bb99456","lastMeasurement":{"value":"6.58","createdAt":"2020-10-17T10:49:51.627Z"}},{"title":"rel. Luftfeuchte","unit":"%","sensorType":"BME280","icon":"osem-humidity","_id":"5e6073fe57703e001bb99455","lastMeasurement":{"value":"53.76","createdAt":"2020-10-17T10:49:51.627Z"}},{"title":"Luftdruck","unit":"Pa","sensorType":"BME280","icon":"osem-barometer","_id":"5e6073fe57703e001bb99454","lastMeasurement":{"value":"96937.66","createdAt":"2020-10-17T10:49:51.627Z"}}],"model":"luftdaten_sds011_bme280","lastMeasurementAt":"2020-10-17T10:49:51.627Z","loc":[{"geometry":{"timestamp":"2020-03-05T03:37:34.146Z","coordinates":[60.658676,56.833041,51],"type":"Point"},"type":"Feature"}]}]'; +import store from '../../../store'; + +var boxes = store.getState().auth.user ? store.getState().auth.user.boxes : null; +store.subscribe(() => { + boxes = store.getState().auth.user ? store.getState().auth.user.boxes : null; +}); +var selectedBox = ''; + Blockly.Blocks['sensebox_osem_connection'] = { init: function () { @@ -17,10 +24,21 @@ Blockly.Blocks['sensebox_osem_connection'] = { .setAlign(Blockly.ALIGN_LEFT) .appendField(Blockly.Msg.senseBox_osem_exposure) .appendField(new Blockly.FieldDropdown([[Blockly.Msg.senseBox_osem_stationary, 'Stationary'], [Blockly.Msg.senseBox_osem_mobile, 'Mobile']]), "type"); - this.appendDummyInput() - .setAlign(Blockly.ALIGN_LEFT) - .appendField("senseBox ID") - .appendField(new Blockly.FieldTextInput("senseBox ID"), "BoxID"); + if (!boxes) { + this.appendDummyInput() + .setAlign(Blockly.ALIGN_LEFT) + .appendField("senseBox ID") + .appendField(new Blockly.FieldTextInput("senseBox ID"), "BoxID"); + } else { + var dropdown = [] + for (var i = 0; i < boxes.length; i++) { + dropdown.push([boxes[i].name, boxes[i]._id]) + } + this.appendDummyInput() + .setAlign(Blockly.ALIGN_LEFT) + .appendField("senseBox ID") + .appendField(new Blockly.FieldDropdown(dropdown), 'BoxID'); + } this.appendDummyInput() .setAlign(Blockly.ALIGN_LEFT) .appendField(Blockly.Msg.senseBox_osem_access_token) @@ -32,14 +50,17 @@ Blockly.Blocks['sensebox_osem_connection'] = { this.setNextStatement(true, null); }, onchange: function (e) { - let boxID = this.getFieldValue('BoxID'); - if (boxID !== 'senseBox ID') { - fetch('https://api.opensensemap.org/boxes/ ' + boxID) - .then(res => res.json()) - .then((data) => { - apiData = data; - }) + selectedBox = this.getFieldValue('BoxID'); + console.log(selectedBox) + if (selectedBox !== '' && boxes) { + var accessToken = boxes.find(element => element._id === selectedBox).access_token + if (accessToken !== undefined) { + this.getField('access_token').setValue(accessToken) + } else { + this.getField('access_token').setValue('access_token') + } } + }, mutationToDom: function () { var container = document.createElement('mutation'); @@ -83,35 +104,42 @@ Blockly.Blocks['sensebox_osem_connection'] = { }; Blockly.Blocks['sensebox_send_to_osem'] = { init: function () { - this.setTooltip(Blockly.Msg.senseBox_send_to_osem_tip); this.setHelpUrl(''); this.setColour(getColour().sensebox); this.appendDummyInput() .appendField(Blockly.Msg.senseBox_send_to_osem); - this.appendValueInput('Value') - .appendField('Phänomen') - .appendField(new Blockly.FieldDropdown( - this.generateOptions), 'SensorID'); + if (boxes) { + this.appendValueInput('Value') + .appendField('Phänomen') + .appendField(new Blockly.FieldDropdown( + this.generateOptions), 'SensorID'); + } else { + this.appendValueInput('Value') + .setAlign(Blockly.ALIGN_LEFT) + .appendField('Phänomen') + .appendField(new Blockly.FieldTextInput( + 'sensorID'), 'SensorID') + } + this.setPreviousStatement(true, null); this.setNextStatement(true, null); }, generateOptions: function () { - var options = [['', '']]; - if (apiData.sensors != undefined) { - for (var i = 0; i < apiData.sensors.length; i++) { - options.push([apiData.sensors[i].title, apiData.sensors[i]._id]); + var dropdown = [['', '']]; + var boxID = selectedBox; + if (boxID !== '' && boxes) { + + let box = boxes.find(el => el._id === boxID); + if (box !== undefined) { + for (var i = 0; i < box.sensors.length; i++) { + dropdown.push([box.sensors[i].title, box.sensors[i]._id]) + } + console.log(dropdown) } } - if (options.length > 1) { - - var dropdown = options.slice(1) - return dropdown; - } else - return options; - - + return dropdown }, /** * Called whenever anything on the workspace changes. @@ -143,4 +171,4 @@ Blockly.Blocks['sensebox_send_to_osem'] = { * Blockly.Blocks['controls_flow_statements'].LOOP_TYPES.push('custom_loop'); */ LOOP_TYPES: ['sensebox_osem_connection'] -}; \ No newline at end of file +}; diff --git a/src/components/Gallery/GalleryHome.js b/src/components/Gallery/GalleryHome.js deleted file mode 100644 index 6e6772a..0000000 --- a/src/components/Gallery/GalleryHome.js +++ /dev/null @@ -1,118 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { connect } from 'react-redux'; - -import clsx from 'clsx'; - -import Breadcrumbs from '../Breadcrumbs'; - -import { Link } from 'react-router-dom'; - -import { fade } from '@material-ui/core/styles/colorManipulator'; -import { withStyles } from '@material-ui/core/styles'; -import Grid from '@material-ui/core/Grid'; -import Paper from '@material-ui/core/Paper'; -import BlocklyWindow from '../Blockly/BlocklyWindow'; -import Divider from '@material-ui/core/Divider'; - - -const styles = (theme) => ({ - outerDiv: { - position: 'absolute', - right: '-30px', - bottom: '-30px', - width: '160px', - height: '160px', - color: fade(theme.palette.secondary.main, 0.6) - }, - outerDivError: { - stroke: fade(theme.palette.error.dark, 0.6), - color: fade(theme.palette.error.dark, 0.6) - }, - outerDivSuccess: { - stroke: fade(theme.palette.primary.main, 0.6), - color: fade(theme.palette.primary.main, 0.6) - }, - outerDivOther: { - stroke: fade(theme.palette.secondary.main, 0.6) - }, - innerDiv: { - width: 'inherit', - height: 'inherit', - display: 'table-cell', - verticalAlign: 'middle', - textAlign: 'center' - } -}); - - - - -class GalleryHome extends Component { - - state = { - gallery: [] - } - - componentDidMount() { - fetch(process.env.REACT_APP_BLOCKLY_API + this.props.location.pathname) - .then(res => res.json()) - .then((data) => { - this.setState({ gallery: data }) - }) - } - - - render() { - return ( -
- - -

Gallery

- - {this.state.gallery.map((gallery, i) => { - return ( - - - -

{gallery.title}

- - - - - - -

{gallery.text}

- - -
-
-
- -
- ) - })} -
-
- ); - }; -} - -GalleryHome.propTypes = { - status: PropTypes.array.isRequired, - change: PropTypes.number.isRequired, -}; - -const mapStateToProps = state => ({ - change: state.tutorial.change, - status: state.tutorial.status -}); - -export default connect(mapStateToProps, null)(withStyles(styles, { withTheme: true })(GalleryHome)); diff --git a/src/components/Gallery/gallery.json b/src/components/Gallery/gallery.json deleted file mode 100644 index 0ff72ae..0000000 --- a/src/components/Gallery/gallery.json +++ /dev/null @@ -1,37 +0,0 @@ -[ - { - "id": 15212, - "title": "Das senseBox Buch Kapitel 1", - "name": "Mario", - "text": "Die Blöcke findest du in der Kategorie \"Schleifen\". Die einfachste Schleife, die du Verwenden kannst, ist der Block \"Wiederhole 10 mal\". Bei diesem Block kannst du die Blöcke, die eine bestimmte Zahl wiederholt werden soll einfach in den offenen Block abschnitt ziehen. ", - "xml": "\n \n \n \n 10\n \n \n \n" - }, - { - "id": 25451, - "title": "Das senseBox Buch Kapitel 2", - "name": "Mario", - "text": "", - "xml": "\n \n \n \n 1\n HIGH\n \n \n \n \n 1000\n \n \n \n \n 1\n LOW\n \n \n \n \n 1000\n \n \n \n \n \n \n \n \n \n \n \n" - }, - { - "id": 3541512, - "title": "Das senseBox Buch Kapitel 3", - "name": "Mario", - "text": "", - "xml": "\n \n \n \n \n \n \n \n \n WHITE,BLACK\n \n \n 1\n \n \n \n \n 0\n \n \n \n \n 0\n \n \n \n \n \n \n \n Helligkeit:\n \n \n \n \n Illuminance\n \n \n \n \n \n \n \n \n \n" - }, - { - "id": 7487454, - "title": "Das senseBox Buch Kapitel 4", - "name": "Mario", - "text": "", - "xml": "\n \n \n \n \n \n \n \n \n WHITE,BLACK\n \n \n 1\n \n \n \n \n 0\n \n \n \n \n 0\n \n \n \n \n \n \n \n Helligkeit:\n \n \n \n \n Illuminance\n \n \n \n \n \n \n \n \n \n" - }, - { - "id": 54541251, - "title": "Das senseBox Buch Kapitel 5", - "name": "Mario", - "text": "", - "xml": "" - } -] \ No newline at end of file diff --git a/src/components/Home.js b/src/components/Home.js index 7729aad..7c385ac 100644 --- a/src/components/Home.js +++ b/src/components/Home.js @@ -4,14 +4,15 @@ import { connect } from 'react-redux'; import { clearStats, workspaceName } from '../actions/workspaceActions'; import * as Blockly from 'blockly/core'; +import { createNameId } from 'mnemonic-id'; -import WorkspaceStats from './WorkspaceStats'; -import WorkspaceFunc from './WorkspaceFunc'; +import WorkspaceStats from './Workspace/WorkspaceStats'; +import WorkspaceFunc from './Workspace/WorkspaceFunc'; import BlocklyWindow from './Blockly/BlocklyWindow'; import CodeViewer from './CodeViewer'; -import TrashcanButtons from './TrashcanButtons'; -import { createNameId } from 'mnemonic-id'; +import TrashcanButtons from './Workspace/TrashcanButtons'; import HintTutorialExists from './Tutorial/HintTutorialExists'; +import Snackbar from './Snackbar'; import Grid from '@material-ui/core/Grid'; import IconButton from '@material-ui/core/IconButton'; @@ -47,29 +48,28 @@ class Home extends Component { state = { codeOn: false, - gallery: [], - share: [], - projectToLoad: undefined, stats: window.localStorage.getItem('stats'), + snackbar: false, + type: '', + key: '', + message: '' } componentDidMount() { - this.setState({ stats: window.localStorage.getItem('stats') }) - this.props.workspaceName(createNameId()); - fetch(process.env.REACT_APP_BLOCKLY_API + this.props.location.pathname) - .then(res => res.json()) - .then((data) => { - this.setState({ projectToLoad: data }) - }) + this.setState({ stats: window.localStorage.getItem('stats') }); + if(!this.props.project){ + this.props.workspaceName(createNameId()); + } + if(this.props.message && this.props.message.id === 'GET_SHARE_FAIL'){ + this.setState({ snackbar: true, key: Date.now(), message: `Das angefragte geteilte Projekt konnte nicht gefunden werden.`, type: 'error' }); + } } - - componentDidUpdate() { + componentDidUpdate(props) { /* Resize and reposition all of the workspace chrome (toolbox, trash, scrollbars etc.) This should be called when something changes that requires recalculating dimensions and positions of the trash, zoom, toolbox, etc. (e.g. window resize). */ - const workspace = Blockly.getMainWorkspace(); Blockly.svgResize(workspace); } @@ -95,7 +95,9 @@ class Home extends Component {
: null } -
+
+ +
@@ -108,18 +110,24 @@ class Home extends Component { - {this.state.projectToLoad ? - < BlocklyWindow blocklyCSS={{ height: '80vH' }} initialXml={this.state.projectToLoad.xml} /> : < BlocklyWindow blocklyCSS={{ height: '80vH' }} /> + {this.props.project ? + < BlocklyWindow blocklyCSS={{ height: '80vH' }} initialXml={this.props.project.xml} /> + : < BlocklyWindow blocklyCSS={{ height: '80vH' }} /> } - {this.state.codeOn ? - : null} + : null} +
); }; @@ -127,8 +135,13 @@ class Home extends Component { Home.propTypes = { clearStats: PropTypes.func.isRequired, - workspaceName: PropTypes.func.isRequired + workspaceName: PropTypes.func.isRequired, + message: PropTypes.object.isRequired }; +const mapStateToProps = state => ({ + message: state.message +}); -export default connect(null, { clearStats, workspaceName })(withStyles(styles, { withTheme: true })(Home)); + +export default connect(mapStateToProps, { clearStats, workspaceName })(withStyles(styles, { withTheme: true })(Home)); diff --git a/src/components/Navbar.js b/src/components/Navbar.js index a7137ed..2bc7b62 100644 --- a/src/components/Navbar.js +++ b/src/components/Navbar.js @@ -2,6 +2,7 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { Link } from 'react-router-dom'; +import { logout } from '../actions/authActions'; import senseboxLogo from './sensebox_logo.svg'; @@ -20,7 +21,7 @@ import ListItemIcon from '@material-ui/core/ListItemIcon'; import ListItemText from '@material-ui/core/ListItemText'; import LinearProgress from '@material-ui/core/LinearProgress'; -import { faBars, faChevronLeft, faBuilding, faIdCard, faEnvelope, faCog, faChalkboardTeacher, faFolderPlus, faTools, faLightbulb } from "@fortawesome/free-solid-svg-icons"; +import { faBars, faChevronLeft, faLayerGroup, faSignInAlt, faSignOutAlt, faCertificate, faUserCircle, faIdCard, faEnvelope, faCog, faChalkboardTeacher, faFolderPlus, faTools, faLightbulb } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; const styles = (theme) => ({ @@ -53,7 +54,7 @@ class Navbar extends Component {
@@ -99,26 +100,45 @@ class Navbar extends Component {
- {[{ text: 'Tutorials', icon: faChalkboardTeacher, link: "/tutorial" }, { text: 'Tutorial-Builder', icon: faTools, link: "/tutorial/builder" }, { text: 'Gallery', icon: faLightbulb, link: "/gallery" }, { text: 'Einstellungen', icon: faCog, link: "/settings" }].map((item, index) => ( - - - - - - - ))} + {[{ text: 'Tutorials', icon: faChalkboardTeacher, link: "/tutorial" }, + { text: 'Tutorial-Builder', icon: faTools, link: "/tutorial/builder", restriction: this.props.user && this.props.user.blocklyRole !== 'user' && this.props.isAuthenticated}, + { text: 'Galerie', icon: faLightbulb, link: "/gallery" }, + { text: 'Projekte', icon: faLayerGroup, link: "/project", restriction: this.props.isAuthenticated }].map((item, index) => { + if(item.restriction || Object.keys(item).filter(attribute => attribute === 'restriction').length === 0){ + return( + + + + + + + ); + } + } + )} - {/* - {[{ text: 'Über uns', icon: faBuilding }, { text: 'Kontakt', icon: faEnvelope }, { text: 'Impressum', icon: faIdCard }].map((item, index) => ( - - - - - ))} - */} + + {[{ text: 'Anmelden', icon: faSignInAlt, link: '/user/login', restriction: !this.props.isAuthenticated }, + { text: 'Konto', icon: faUserCircle, link: '/user', restriction: this.props.isAuthenticated }, + { text: 'MyBadges', icon: faCertificate, link: '/user/badge', restriction: this.props.isAuthenticated }, + { text: 'Abmelden', icon: faSignOutAlt, function: this.props.logout, restriction: this.props.isAuthenticated }, + { text: 'Einstellungen', icon: faCog, link: "/settings" }].map((item, index) => { + if(item.restriction || Object.keys(item).filter(attribute => attribute === 'restriction').length === 0){ + return( + + {item.function(); this.toggleDrawer();} : this.toggleDrawer}> + + + + + ); + } + } + )} + - {this.props.isLoading ? + {this.props.tutorialIsLoading || this.props.projectIsLoading ? : null} @@ -127,11 +147,17 @@ class Navbar extends Component { } Navbar.propTypes = { - isLoading: PropTypes.bool.isRequired + tutorialIsLoading: PropTypes.bool.isRequired, + projectIsLoading: PropTypes.bool.isRequired, + isAuthenticated: PropTypes.bool.isRequired, + user: PropTypes.object }; const mapStateToProps = state => ({ - isLoading: state.tutorial.progress, + tutorialIsLoading: state.tutorial.progress, + projectIsLoading: state.project.progress, + isAuthenticated: state.auth.isAuthenticated, + user: state.auth.user }); -export default connect(mapStateToProps, null)(withStyles(styles, { withTheme: true })(withRouter(Navbar))); +export default connect(mapStateToProps, { logout })(withStyles(styles, { withTheme: true })(withRouter(Navbar))); diff --git a/src/components/Project/Project.js b/src/components/Project/Project.js new file mode 100644 index 0000000..a3a2403 --- /dev/null +++ b/src/components/Project/Project.js @@ -0,0 +1,103 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { workspaceName } from '../../actions/workspaceActions'; +import { getProject, resetProject } from '../../actions/projectActions'; +import { clearMessages, returnErrors } from '../../actions/messageActions'; + +import axios from 'axios'; +import { withRouter } from 'react-router-dom'; +import { createNameId } from 'mnemonic-id'; + +import Home from '../Home'; +import Breadcrumbs from '../Breadcrumbs'; + +import Backdrop from '@material-ui/core/Backdrop'; +import CircularProgress from '@material-ui/core/CircularProgress'; + + +class Project extends Component { + + componentDidMount() { + this.props.resetProject(); + this.getProject(); + } + + componentDidUpdate(props) { + if(props.location.pathname !== this.props.location.pathname || + props.match.params[`${this.props.type}Id`] !== this.props.match.params[`${this.props.type}Id`]){ + if(this.props.message.msg){ + this.props.clearMessages(); + } + this.getProject(); + } + if(this.props.message !== props.message){ + if(this.props.message.id === 'PROJECT_EMPTY' || this.props.message.id === 'GET_PROJECT_FAIL'){ + if(this.props.type!=='share'){ + this.props.returnErrors('', 404, 'GET_PROJECT_FAIL'); + this.props.history.push(`/${this.props.type}`); + } else { + this.props.history.push('/'); + this.props.returnErrors('', 404, 'GET_SHARE_FAIL'); + } + } + else if(this.props.message.id === 'GET_PROJECT_SUCCESS'){ + this.props.workspaceName(this.props.project.title); + } + else if(this.props.message.id === 'PROJECT_DELETE_SUCCESS' || this.props.message.id === 'GALLERY_DELETE_SUCCESS'){ + this.props.history.push(`/${this.props.type}`); + } + } + } + + componentWillUnmount() { + this.props.resetProject(); + this.props.workspaceName(null); + } + + getProject = () => { + var id = this.props.location.pathname.replace(/\/[a-z]{1,}\//,''); + var param = this.props.location.pathname.replace(`/${id}`,'').replace('/',''); + console.log('param', param); + console.log(id); + this.props.getProject(param, id); + } + + render() { + var data = this.props.type === 'project' ? 'Projekte' : 'Galerie'; + return ( + this.props.progress ? + + + + : this.props.project ? +
+ {this.props.type !== 'share' ? + + : null} + +
: null + ); + }; +} + +Project.propTypes = { + workspaceName: PropTypes.func.isRequired, + getProject: PropTypes.func.isRequired, + resetProject: PropTypes.func.isRequired, + clearMessages: PropTypes.func.isRequired, + returnErrors: PropTypes.func.isRequired, + project: PropTypes.object.isRequired, + type: PropTypes.string.isRequired, + message: PropTypes.object.isRequired, + progress: PropTypes.bool.isRequired +}; + +const mapStateToProps = state => ({ + project: state.project.projects[0], + progress: state.project.progress, + type: state.project.type, + message: state.message +}); + +export default connect(mapStateToProps, { workspaceName, getProject, resetProject, clearMessages, returnErrors })(withRouter(Project)); diff --git a/src/components/Project/ProjectHome.js b/src/components/Project/ProjectHome.js new file mode 100644 index 0000000..07e559b --- /dev/null +++ b/src/components/Project/ProjectHome.js @@ -0,0 +1,164 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { getProjects, resetProject } from '../../actions/projectActions'; +import { clearMessages } from '../../actions/messageActions'; + +import axios from 'axios'; +import { Link, withRouter } from 'react-router-dom'; + +import Breadcrumbs from '../Breadcrumbs'; +import BlocklyWindow from '../Blockly/BlocklyWindow'; +import Snackbar from '../Snackbar'; +import WorkspaceFunc from '../Workspace/WorkspaceFunc'; + +import { withStyles } from '@material-ui/core/styles'; +import Grid from '@material-ui/core/Grid'; +import Paper from '@material-ui/core/Paper'; +import Divider from '@material-ui/core/Divider'; +import Typography from '@material-ui/core/Typography'; +import Backdrop from '@material-ui/core/Backdrop'; +import CircularProgress from '@material-ui/core/CircularProgress'; + +const styles = (theme) => ({ + link: { + color: theme.palette.primary.main, + textDecoration: 'none', + '&:hover': { + color: theme.palette.primary.main, + textDecoration: 'underline' + } + } +}); + + +class ProjectHome extends Component { + + state = { + snackbar: false, + type: '', + key: '', + message: '' + } + + componentDidMount() { + var type = this.props.location.pathname.replace('/',''); + this.props.getProjects(type); + if(this.props.message){ + if(this.props.message.id === 'PROJECT_DELETE_SUCCESS'){ + this.setState({ snackbar: true, key: Date.now(), message: `Dein Projekt wurde erfolgreich gelöscht.`, type: 'success' }); + } + else if(this.props.message.id === 'GALLERY_DELETE_SUCCESS'){ + this.setState({ snackbar: true, key: Date.now(), message: `Dein Galerie-Projekt wurde erfolgreich gelöscht.`, type: 'success' }); + } + else if(this.props.message.id === 'GET_PROJECT_FAIL'){ + this.setState({ snackbar: true, key: Date.now(), message: `Dein angefragtes ${type === 'gallery' ? 'Galerie-':''}Projekt konnte nicht gefunden werden.`, type: 'error' }); + } + } + } + + componentDidUpdate(props) { + if(props.location.pathname !== this.props.location.pathname){ + this.setState({snackbar: false}); + this.props.getProjects(this.props.location.pathname.replace('/','')); + } + if(props.message !== this.props.message){ + if(this.props.message.id === 'PROJECT_DELETE_SUCCESS'){ + this.setState({ snackbar: true, key: Date.now(), message: `Dein Projekt wurde erfolgreich gelöscht.`, type: 'success' }); + } + else if(this.props.message.id === 'GALLERY_DELETE_SUCCESS'){ + this.setState({ snackbar: true, key: Date.now(), message: `Dein Galerie-Projekt wurde erfolgreich gelöscht.`, type: 'success' }); + } + } + } + + componentWillUnmount() { + this.props.resetProject(); + this.props.clearMessages(); + } + + render() { + var data = this.props.location.pathname === '/project' ? 'Projekte' : 'Galerie'; + return ( +
+ + +

{data}

+ {this.props.progress ? + + + + : +
+ {this.props.projects.length > 0 ? + + {this.props.projects.map((project, i) => { + return ( + + + +

{project.title}

+ + + {project.description} + + {this.props.user && this.props.user.email === project.creator ? +
+ +
+ +
+
+ : null} +
+
+ ) + })} +
+ :
+ Es sind aktuell keine Projekte vorhanden. + {this.props.location.pathname.replace('/','') === 'project' ? + Erstelle jetzt dein eigenes Projekt oder lasse dich von Projektbeispielen in der Galerie inspirieren. + : null} +
+ } +
+ } + +
+ ); + }; +} + +ProjectHome.propTypes = { + getProjects: PropTypes.func.isRequired, + resetProject: PropTypes.func.isRequired, + clearMessages: PropTypes.func.isRequired, + projects: PropTypes.array.isRequired, + progress: PropTypes.bool.isRequired, + user: PropTypes.object, + message: PropTypes.object.isRequired +}; + +const mapStateToProps = state => ({ + projects: state.project.projects, + progress: state.project.progress, + user: state.auth.user, + message: state.message +}); + + +export default connect(mapStateToProps, { getProjects, resetProject, clearMessages })(withStyles(styles, { withTheme: true })(withRouter(ProjectHome))); diff --git a/src/components/Route/IsLoggedRoute.js b/src/components/Route/IsLoggedRoute.js new file mode 100644 index 0000000..f071b2b --- /dev/null +++ b/src/components/Route/IsLoggedRoute.js @@ -0,0 +1,39 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; + +import { Route, Redirect } from 'react-router-dom'; + + +class IsLoggedRoute extends Component { + + render() { + return ( + + !this.props.isAuthenticated ? ( + this.props.children + ) : ( + + ) + } + /> + ); + } +} + +IsLoggedRoute.propTypes = { + isAuthenticated: PropTypes.bool.isRequired +}; + +const mapStateToProps = state => ({ + isAuthenticated: state.auth.isAuthenticated, +}); + +export default connect(mapStateToProps, null)(IsLoggedRoute); diff --git a/src/components/Route/PrivateRoute.js b/src/components/Route/PrivateRoute.js new file mode 100644 index 0000000..ffd321a --- /dev/null +++ b/src/components/Route/PrivateRoute.js @@ -0,0 +1,41 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; + +import { Route, Redirect, withRouter } from 'react-router-dom'; + + +class PrivateRoute extends Component { + + render() { + return ( + + this.props.isAuthenticated ? ( + this.props.children + ) : (()=>{ + return ( + + ) + })() + } + /> + ); + } +} + +PrivateRoute.propTypes = { + isAuthenticated: PropTypes.bool.isRequired +}; + +const mapStateToProps = state => ({ + isAuthenticated: state.auth.isAuthenticated +}); + +export default connect(mapStateToProps, null)(withRouter(PrivateRoute)); diff --git a/src/components/Route/PrivateRouteCreator.js b/src/components/Route/PrivateRouteCreator.js new file mode 100644 index 0000000..0efd48c --- /dev/null +++ b/src/components/Route/PrivateRouteCreator.js @@ -0,0 +1,45 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; + +import { Route, Redirect, withRouter } from 'react-router-dom'; + + +class PrivateRoute extends Component { + + render() { + return ( + + this.props.isAuthenticated && + this.props.user && + this.props.user.blocklyRole !== 'user' ? ( + this.props.children + ) : (()=>{ + return ( + + ) + })() + } + /> + ); + } +} + +PrivateRoute.propTypes = { + isAuthenticated: PropTypes.bool.isRequired, + user: PropTypes.object +}; + +const mapStateToProps = state => ({ + isAuthenticated: state.auth.isAuthenticated, + user: state.auth.user +}); + +export default connect(mapStateToProps, null)(withRouter(PrivateRoute)); diff --git a/src/components/Route/Routes.js b/src/components/Route/Routes.js new file mode 100644 index 0000000..3121edf --- /dev/null +++ b/src/components/Route/Routes.js @@ -0,0 +1,83 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { visitPage } from '../../actions/generalActions'; + +import { Route, Switch, withRouter } from 'react-router-dom'; + +import PrivateRoute from './PrivateRoute'; +import PrivateRouteCreator from './PrivateRouteCreator'; +import IsLoggedRoute from './IsLoggedRoute'; + +import Home from '../Home'; +import Tutorial from '../Tutorial/Tutorial'; +import TutorialHome from '../Tutorial/TutorialHome'; +import Builder from '../Tutorial/Builder/Builder'; +import NotFound from '../NotFound'; +import ProjectHome from '../Project/ProjectHome'; +import Project from '../Project/Project'; +import Settings from '../Settings/Settings'; +import Impressum from '../Impressum'; +import Privacy from '../Privacy'; +import Login from '../User/Login'; +import Account from '../User/Account'; +import MyBadges from '../User/MyBadges'; + + +class Routes extends Component { + + componentDidUpdate() { + this.props.visitPage(); + } + + render() { + return ( +
+ + + {/* Tutorials */} + + + + + + {/* Sharing */} + + {/* Gallery-Projects */} + + + {/* User-Projects */} + + + + + + + {/* User */} + + + + + + + + + + {/* settings */} + + {/* privacy */} + + + {/* Not Found */} + + +
+ ); + } +} + +Home.propTypes = { + visitPage: PropTypes.func.isRequired +}; + +export default connect(null, { visitPage })(withRouter(Routes)); diff --git a/src/components/Routes.js b/src/components/Routes.js deleted file mode 100644 index cf6b559..0000000 --- a/src/components/Routes.js +++ /dev/null @@ -1,50 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { connect } from 'react-redux'; -import { visitPage } from '../actions/generalActions'; - -import { Route, Switch, withRouter } from 'react-router-dom'; - -import Home from './Home'; -import Tutorial from './Tutorial/Tutorial'; -import TutorialHome from './Tutorial/TutorialHome'; -import Builder from './Tutorial/Builder/Builder'; -import NotFound from './NotFound'; -import GalleryHome from './Gallery/GalleryHome'; -import Settings from './Settings/Settings'; -import Impressum from './Impressum'; -import Privacy from './Privacy'; - - -class Routes extends Component { - - componentDidUpdate() { - this.props.visitPage(); - } - - render() { - return ( -
- - - - - - - - - - - - - -
- ); - } -} - -Home.propTypes = { - visitPage: PropTypes.func.isRequired -}; - -export default connect(null, { visitPage })(withRouter(Routes)); diff --git a/src/components/Snackbar.js b/src/components/Snackbar.js index 16fd4c2..55e7429 100644 --- a/src/components/Snackbar.js +++ b/src/components/Snackbar.js @@ -35,6 +35,12 @@ class Snackbar extends Component { } } + componentDidUpdate(){ + if(!this.state.open){ + clearTimeout(this.timeout); + } + } + componentWillUnmount(){ if(this.state.open){ clearTimeout(this.timeout); diff --git a/src/components/Tutorial/Assessment.js b/src/components/Tutorial/Assessment.js index 19fea9b..75e180d 100644 --- a/src/components/Tutorial/Assessment.js +++ b/src/components/Tutorial/Assessment.js @@ -5,7 +5,7 @@ import { workspaceName } from '../../actions/workspaceActions'; import BlocklyWindow from '../Blockly/BlocklyWindow'; import CodeViewer from '../CodeViewer'; -import WorkspaceFunc from '../WorkspaceFunc'; +import WorkspaceFunc from '../Workspace/WorkspaceFunc'; import withWidth, { isWidthDown } from '@material-ui/core/withWidth'; import Grid from '@material-ui/core/Grid'; @@ -25,10 +25,10 @@ class Assessment extends Component { } render() { - var tutorialId = this.props.tutorial.id; + var tutorialId = this.props.tutorial._id; var currentTask = this.props.step; - var status = this.props.status.filter(status => status.id === tutorialId)[0]; - var taskIndex = status.tasks.findIndex(task => task.id === currentTask.id); + var status = this.props.status.filter(status => status._id === tutorialId)[0]; + var taskIndex = status.tasks.findIndex(task => task._id === currentTask._id); var statusTask = status.tasks[taskIndex]; return ( diff --git a/src/components/Tutorial/Badge.js b/src/components/Tutorial/Badge.js new file mode 100644 index 0000000..97103fa --- /dev/null +++ b/src/components/Tutorial/Badge.js @@ -0,0 +1,118 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { assigneBadge } from '../../actions/tutorialActions'; + +import Dialog from '../Dialog'; + +import { Link } from 'react-router-dom'; + +import { withStyles } from '@material-ui/core/styles'; +import Paper from '@material-ui/core/Paper'; +import Typography from '@material-ui/core/Typography'; +import Avatar from '@material-ui/core/Avatar'; + +const styles = (theme) => ({ + link: { + color: theme.palette.primary.main, + textDecoration: 'none', + '&:hover': { + color: theme.palette.primary.main, + textDecoration: 'underline' + } + } +}); + + +class Badge extends Component { + + state = { + open: false, + title: '', + content: '' + }; + + componentDidUpdate(props){ + if(this.props.message.id === 'TUTORIAL_CHECK_SUCCESS'){ + if(this.props.tutorial.badge){ + // is connected to MyBadges? + if(this.props.isAuthenticated && this.props.user && this.props.user.badge){ + if(this.props.user.badges && !this.props.user.badges.includes(this.props.tutorial.badge)){ + if(this.isSuccess()){ + this.props.assigneBadge(this.props.tutorial.badge); + } + } + } + } + } + if(props.message !== this.props.message){ + if(this.props.message.id === 'ASSIGNE_BADGE_SUCCESS'){ + this.setState({title: `Badge: ${this.props.message.msg.name}`, content: `Herzlichen Glückwunsch! Du hast den Badge ${this.props.message.msg.name} erhalten.`, open: true}); + } + } + } + + isSuccess = () => { + var tutorialId = this.props.tutorial._id; + var status = this.props.status.filter(status => status._id === tutorialId)[0]; + var tasks = status.tasks; + var success = tasks.filter(task => task.type === 'success').length / tasks.length; + if(success===1){ + return true; + } + return false; + } + + toggleDialog = () => { + this.setState({ open: !this.state, title: '', content: '' }); + } + + render() { + return ( + {this.toggleDialog();}} + onClick={() => {this.toggleDialog();}} + button={'Schließen'} + > +
+ + {this.props.message.msg.image && this.props.message.msg.image.path ? + + : } + +
{this.props.message.msg.name}
+
+
+ + Eine Übersicht über alle erhaltenen Badges im Kontext Blockly for senseBox findest du hier. + +
+
+ ); + }; +} + +Badge.propTypes = { + assigneBadge: PropTypes.func.isRequired, + status: PropTypes.array.isRequired, + change: PropTypes.number.isRequired, + tutorial: PropTypes.object.isRequired, + user: PropTypes.object, + isAuthenticated: PropTypes.bool.isRequired, + message: PropTypes.object.isRequired +}; + +const mapStateToProps = state => ({ + change: state.tutorial.change, + status: state.tutorial.status, + tutorial: state.tutorial.tutorials[0], + user: state.auth.user, + isAuthenticated: state.auth.isAuthenticated, + message: state.message +}); + +export default connect(mapStateToProps, { assigneBadge })(withStyles(styles, { withTheme: true })(Badge)); diff --git a/src/components/Tutorial/Builder/Builder.js b/src/components/Tutorial/Builder/Builder.js index 4c88177..78c7657 100644 --- a/src/components/Tutorial/Builder/Builder.js +++ b/src/components/Tutorial/Builder/Builder.js @@ -1,10 +1,14 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; -import { checkError, readJSON, jsonString, progress, resetTutorial } from '../../../actions/tutorialBuilderActions'; +import { checkError, readJSON, jsonString, progress, tutorialId, resetTutorial as resetTutorialBuilder} from '../../../actions/tutorialBuilderActions'; +import { getTutorials, resetTutorial, deleteTutorial } from '../../../actions/tutorialActions'; +import { clearMessages } from '../../../actions/messageActions'; + +import axios from 'axios'; +import { withRouter } from 'react-router-dom'; import { saveAs } from 'file-saver'; - import { detectWhitespacesAndReturnReadableResult } from '../../../helpers/whitespace'; import Breadcrumbs from '../../Breadcrumbs'; @@ -19,6 +23,13 @@ import Backdrop from '@material-ui/core/Backdrop'; import CircularProgress from '@material-ui/core/CircularProgress'; import Divider from '@material-ui/core/Divider'; import FormHelperText from '@material-ui/core/FormHelperText'; +import Radio from '@material-ui/core/Radio'; +import RadioGroup from '@material-ui/core/RadioGroup'; +import FormControlLabel from '@material-ui/core/FormControlLabel'; +import InputLabel from '@material-ui/core/InputLabel'; +import MenuItem from '@material-ui/core/MenuItem'; +import FormControl from '@material-ui/core/FormControl'; +import Select from '@material-ui/core/Select'; const styles = (theme) => ({ backdrop: { @@ -27,14 +38,24 @@ const styles = (theme) => ({ }, errorColor: { color: theme.palette.error.dark + }, + errorButton: { + marginTop: '5px', + height: '40px', + backgroundColor: theme.palette.error.dark, + '&:hover':{ + backgroundColor: theme.palette.error.dark + } } }); + class Builder extends Component { constructor(props) { super(props); this.state = { + tutorial: 'new', open: false, title: '', content: '', @@ -46,44 +67,32 @@ class Builder extends Component { this.inputRef = React.createRef(); } - componentWillUnmount() { - this.reset(); + componentDidMount() { + this.props.getTutorials(); } - submit = () => { - if (this.props.id === null) { - var randomID = Date.now(); - } else { - randomID = this.props.id; - } - - var isError = this.props.checkError(); - if (isError) { - this.setState({ snackbar: true, key: Date.now(), message: `Die Angaben für das Tutorial sind nicht vollständig.`, type: 'error' }); - window.scrollTo(0, 0); - } - else { - // export steps without attribute 'url' - var steps = this.props.steps.map(step => { - if (step.url) { - delete step.url; - } - return step; - }); - var tutorial = { - id: randomID, - title: this.props.title, - steps: steps + componentDidUpdate(props, state) { + if(props.message !== this.props.message){ + if(this.props.message.id === 'GET_TUTORIALS_FAIL'){ + // alert(this.props.message.msg); + this.props.clearMessages(); + } + else if(this.props.message.id === 'TUTORIAL_DELETE_SUCCESS'){ + this.onChange('new'); + this.setState({ snackbar: true, key: Date.now(), message: `Das Tutorial wurde erfolgreich gelöscht.`, type: 'success' }); + } + else if(this.props.message.id === 'TUTORIAL_DELETE_FAIL'){ + this.setState({ snackbar: true, key: Date.now(), message: `Fehler beim Löschen des Tutorials. Versuche es noch einmal.`, type: 'error' }); } - var blob = new Blob([JSON.stringify(tutorial)], { type: 'text/json' }); - saveAs(blob, `${detectWhitespacesAndReturnReadableResult(tutorial.title)}.json`); } } - reset = () => { + componentWillUnmount() { + this.resetFull(); this.props.resetTutorial(); - this.setState({ snackbar: true, key: Date.now(), message: `Das Tutorial wurde erfolgreich zurückgesetzt.`, type: 'success' }); - window.scrollTo(0, 0); + if(this.props.message.msg){ + this.props.clearMessages(); + } } uploadJsonFile = (jsonFile) => { @@ -131,49 +140,218 @@ class Builder extends Component { this.setState({ open: !this.state }); } + onChange = (value) => { + this.props.resetTutorialBuilder(); + this.props.tutorialId(''); + this.setState({tutorial: value}); + } + + onChangeId = (value) => { + this.props.tutorialId(value); + if(this.state.tutorial === 'change'){ + this.props.progress(true); + var tutorial = this.props.tutorials.filter(tutorial => tutorial._id === value)[0]; + this.props.readJSON(tutorial); + this.setState({ snackbar: true, key: Date.now(), message: `Das ausgewählte Tutorial "${tutorial.title}" wurde erfolgreich übernommen.`, type: 'success' }); + } + } + + resetFull = () => { + this.props.resetTutorialBuilder(); + this.setState({ snackbar: true, key: Date.now(), message: `Das Tutorial wurde erfolgreich zurückgesetzt.`, type: 'success' }); + window.scrollTo(0, 0); + } + + resetTutorial = () => { + var tutorial = this.props.tutorials.filter(tutorial => tutorial._id === this.props.id)[0]; + this.props.readJSON(tutorial); + this.setState({ snackbar: true, key: Date.now(), message: `Das Tutorial ${tutorial.title} wurde erfolgreich auf den ursprünglichen Stand zurückgesetzt.`, type: 'success' }); + window.scrollTo(0, 0); + } + + submit = () => { + var isError = this.props.checkError(); + if (isError) { + this.setState({ snackbar: true, key: Date.now(), message: `Die Angaben für das Tutorial sind nicht vollständig.`, type: 'error' }); + window.scrollTo(0, 0); + return false; + } + else { + // export steps without attribute 'url' + var steps = this.props.steps; + var newTutorial = new FormData(); + newTutorial.append('title', this.props.title); + newTutorial.append('badge', this.props.badge); + steps.forEach((step, i) => { + newTutorial.append(`steps[${i}][type]`, step.type); + newTutorial.append(`steps[${i}][headline]`, step.headline); + newTutorial.append(`steps[${i}][text]`, step.text); + if(i === 0 && step.type === 'instruction'){ + if(step.requirements){ // optional + step.requirements.forEach((requirement, j) => { + newTutorial.append(`steps[${i}][requirements][${j}]`, requirement); + }); + } + step.hardware.forEach((hardware, j) => { + newTutorial.append(`steps[${i}][hardware][${j}]`, hardware); + }); + } + if(step.xml){ // optional + newTutorial.append(`steps[${i}][xml]`, step.xml); + } + if(step.media){ // optional + if(step.media.youtube){ + newTutorial.append(`steps[${i}][media][youtube]`, step.media.youtube); + } + if(step.media.picture){ + newTutorial.append(`steps[${i}][media][picture]`, step.media.picture); + } + } + }); + return newTutorial; + } + } + + submitNew = () => { + var newTutorial = this.submit(); + if(newTutorial){ + axios.post(`${process.env.REACT_APP_BLOCKLY_API}/tutorial/`, newTutorial) + .then(res => { + var tutorial = res.data.tutorial; + this.props.history.push(`/tutorial/${tutorial._id}`); + }) + .catch(err => { + this.setState({ snackbar: true, key: Date.now(), message: `Fehler beim Erstellen des Tutorials. Versuche es noch einmal.`, type: 'error' }); + window.scrollTo(0, 0); + }); + } + } + + submitUpdate = () => { + var updatedTutorial = this.submit(); + if(updatedTutorial){ + axios.put(`${process.env.REACT_APP_BLOCKLY_API}/tutorial/${this.props.id}`, updatedTutorial) + .then(res => { + var tutorial = res.data.tutorial; + this.props.history.push(`/tutorial/${tutorial._id}`); + }) + .catch(err => { + this.setState({ snackbar: true, key: Date.now(), message: `Fehler beim Ändern des Tutorials. Versuche es noch einmal.`, type: 'error' }); + window.scrollTo(0, 0); + }); + } + } render() { + var filteredTutorials = this.props.tutorials.filter(tutorial => tutorial.creator === this.props.user.email); return (

Tutorial-Builder

- {/*upload JSON*/} -
- { this.uploadJsonFile(e.target.files[0]) }} - id="open-json" - type="file" + this.onChange(e.target.value)}> + } + label="neues Tutorial erstellen" + labelPlacement="end" /> - - -
+ {filteredTutorials.length > 0 ? +
+ } + label="bestehendes Tutorial ändern" + labelPlacement="end" + /> + } + label="bestehendes Tutorial löschen" + labelPlacement="end" + /> +
+ : null} + + - {/*Tutorial-Builder-Form*/} - {this.props.error.type ? - {`Ein Tutorial muss mindestens jeweils eine Instruktion und eine Aufgabe enthalten.`} - : null} - {/* */} - + {this.state.tutorial === 'new' ? + /*upload JSON*/ +
+ { this.uploadJsonFile(e.target.files[0]) }} + id="open-json" + type="file" + /> + + +
+ : + Tutorial + + + } - {this.props.steps.map((step, i) => - - )} + - {/*submit or reset*/} - - - + {this.state.tutorial === 'new' || (this.state.tutorial === 'change' && this.props.id !== '') ? + /*Tutorial-Builder-Form*/ +
+ {this.props.error.type ? + {`Ein Tutorial muss mindestens jeweils eine Instruktion und eine Aufgabe enthalten.`} + : null} + {/* */} + + - - - + {this.props.steps.map((step, i) => + + )} + + {/*submit or reset*/} + + {this.state.tutorial === 'new' ? +
+ + +
+ :
+ + +
+ } + + + + +
+ : null} + + {this.state.tutorial === 'delete' && this.props.id !== '' ? + + : null} ({ title: state.builder.title, + badge: state.builder.badge, id: state.builder.id, steps: state.builder.steps, change: state.builder.change, error: state.builder.error, json: state.builder.json, - isProgress: state.builder.progress + isProgress: state.builder.progress, + tutorials: state.tutorial.tutorials, + message: state.message, + user: state.auth.user, }); -export default connect(mapStateToProps, { checkError, readJSON, jsonString, progress, resetTutorial })(withStyles(styles, { withTheme: true })(Builder)); +export default connect(mapStateToProps, { checkError, readJSON, jsonString, progress, tutorialId, resetTutorialBuilder, getTutorials, resetTutorial, clearMessages, deleteTutorial })(withStyles(styles, { withTheme: true })(withRouter(Builder))); diff --git a/src/components/Tutorial/Builder/Media.js b/src/components/Tutorial/Builder/Media.js index 923aee6..999ece4 100644 --- a/src/components/Tutorial/Builder/Media.js +++ b/src/components/Tutorial/Builder/Media.js @@ -84,7 +84,7 @@ class Media extends Component { this.setState({ error: false }); this.props.changeContent(URL.createObjectURL(pic), this.props.index, 'url'); } - this.props.changeContent(pic.name, this.props.index, 'media', 'picture'); + this.props.changeContent(pic, this.props.index, 'media', 'picture'); } render() { @@ -121,8 +121,7 @@ class Media extends Component {
{!this.props.error ?
- {`Beachte, dass das Foto zusätzlich in den Ordner public/media/tutorial unter dem Namen '${this.props.picture}' abgespeichert werden muss.`} - {this.props.url + {''}
:
{ var requirements = this.props.value; - var value = parseInt(e.target.value) + var value = e.target.value; if (e.target.checked) { requirements.push(value); } @@ -50,13 +32,13 @@ class Requirements extends Component { Voraussetzungen Beachte, dass die Reihenfolge des Anhakens maßgebend ist. - {this.props.tutorials.map((tutorial, i) => + {this.props.tutorials.filter(tutorial => tutorial._id !== this.props.id).map((tutorial, i) => id === tutorial.id).length > 0} + value={tutorial._id} + checked={this.props.value.filter(id => id === tutorial._id).length > 0} onChange={(e) => this.onChange(e)} name="requirements" color="primary" @@ -72,16 +54,15 @@ class Requirements extends Component { } Requirements.propTypes = { - getTutorials: PropTypes.func.isRequired, - resetTutorial: PropTypes.func.isRequired, - clearMessages: PropTypes.func.isRequired, - changeContent: PropTypes.func.isRequired + changeContent: PropTypes.func.isRequired, + id: PropTypes.string.isRequired, + tutorials: PropTypes.array.isRequired }; const mapStateToProps = state => ({ change: state.builder.change, - tutorials: state.tutorial.tutorials, - message: state.message + id: state.builder.id, + tutorials: state.tutorial.tutorials }); -export default connect(mapStateToProps, { changeContent, getTutorials, resetTutorial, clearMessages })(Requirements); +export default connect(mapStateToProps, { changeContent })(Requirements); diff --git a/src/components/Tutorial/Builder/Textfield.js b/src/components/Tutorial/Builder/Textfield.js index fa39547..74a57f1 100644 --- a/src/components/Tutorial/Builder/Textfield.js +++ b/src/components/Tutorial/Builder/Textfield.js @@ -1,7 +1,7 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; -import { tutorialTitle, jsonString, changeContent, setError, deleteError } from '../../../actions/tutorialBuilderActions'; +import { tutorialTitle, tutorialBadge, jsonString, changeContent, setError, deleteError } from '../../../actions/tutorialBuilderActions'; import { withStyles } from '@material-ui/core/styles'; import OutlinedInput from '@material-ui/core/OutlinedInput'; @@ -42,6 +42,9 @@ class Textfield extends Component { else if(this.props.property === 'json'){ this.props.jsonString(value); } + else if(this.props.property === 'badge'){ + this.props.tutorialBadge(value); + } else { this.props.changeContent(value, this.props.index, this.props.property, this.props.property2); } @@ -86,8 +89,9 @@ class Textfield extends Component { Textfield.propTypes = { tutorialTitle: PropTypes.func.isRequired, + tutorialBadge: PropTypes.func.isRequired, jsonString: PropTypes.func.isRequired, changeContent: PropTypes.func.isRequired, }; -export default connect(null, { tutorialTitle, jsonString, changeContent, setError, deleteError })(withStyles(styles, { withTheme: true })(Textfield)); +export default connect(null, { tutorialTitle, tutorialBadge, jsonString, changeContent, setError, deleteError })(withStyles(styles, { withTheme: true })(Textfield)); diff --git a/src/components/Tutorial/Instruction.js b/src/components/Tutorial/Instruction.js index d0ecf02..ade6dfb 100644 --- a/src/components/Tutorial/Instruction.js +++ b/src/components/Tutorial/Instruction.js @@ -22,11 +22,11 @@ class Instruction extends Component { {isHardware ? : null} {areRequirements > 0 ? - : null} + : null} {step.media ? step.media.picture ?
- +
: step.media.youtube ? /*16:9; width: 800px; height: width/16*9=450px*/ @@ -39,7 +39,7 @@ class Instruction extends Component { : null} {step.xml ? - + ({ class Requirement extends Component { render() { - var tutorialIds = this.props.tutorialIds; + var requirements = this.props.requirements; + var tutorialIds = requirements.map(requirement => requirement._id); return (
Bevor du mit diesem Tutorial fortfährst solltest du folgende Tutorials erfolgreich abgeschlossen haben: {tutorialIds.map((tutorialId, i) => { - // title must be provided together with ids - // var title = tutorials.filter(tutorial => tutorial.id === tutorialId)[0].title; - var status = this.props.status.filter(status => status.id === tutorialId)[0]; + var title = requirements[i].title + var status = this.props.status.filter(status => status._id === tutorialId)[0]; var tasks = status.tasks; var error = status.tasks.filter(task => task.type === 'error').length > 0; var success = status.tasks.filter(task => task.type === 'success').length / tasks.length @@ -98,7 +98,7 @@ class Requirement extends Component {
- {/*title*/}Name hinzufügen über Datenbankeintrag + {title}
) @@ -112,12 +112,12 @@ class Requirement extends Component { Requirement.propTypes = { status: PropTypes.array.isRequired, - change: PropTypes.number.isRequired, + change: PropTypes.number.isRequired }; const mapStateToProps = state => ({ change: state.tutorial.change, - status: state.tutorial.status + status: state.tutorial.status, }); export default connect(mapStateToProps, null)(withStyles(styles, { withTheme: true })(withRouter(Requirement))); diff --git a/src/components/Tutorial/SolutionCheck.js b/src/components/Tutorial/SolutionCheck.js index 37cafa7..f8c1383 100644 --- a/src/components/Tutorial/SolutionCheck.js +++ b/src/components/Tutorial/SolutionCheck.js @@ -5,7 +5,7 @@ import { tutorialCheck, tutorialStep } from '../../actions/tutorialActions'; import { withRouter } from 'react-router-dom'; -import Compile from '../Compile'; +import Compile from '../Workspace/Compile'; import Dialog from '../Dialog'; import { checkXml } from '../../helpers/compareXml'; @@ -15,15 +15,15 @@ import IconButton from '@material-ui/core/IconButton'; import Tooltip from '@material-ui/core/Tooltip'; import Button from '@material-ui/core/Button'; -import { faPlay } from "@fortawesome/free-solid-svg-icons"; +import { faClipboardCheck } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; const styles = (theme) => ({ compile: { - backgroundColor: theme.palette.primary.main, + backgroundColor: theme.palette.button.compile, color: theme.palette.primary.contrastText, '&:hover': { - backgroundColor: theme.palette.primary.main, + backgroundColor: theme.palette.button.compile, color: theme.palette.primary.contrastText, } } @@ -63,7 +63,7 @@ class SolutionCheck extends Component { style={{ width: '40px', height: '40px', marginRight: '5px' }} onClick={() => this.check()} > - + diff --git a/src/components/Tutorial/StepperHorizontal.js b/src/components/Tutorial/StepperHorizontal.js index acfccca..9886336 100644 --- a/src/components/Tutorial/StepperHorizontal.js +++ b/src/components/Tutorial/StepperHorizontal.js @@ -50,9 +50,9 @@ const styles = (theme) => ({ class StepperHorizontal extends Component { render() { - var tutorialId = this.props.tutorial.id; + var tutorialId = this.props.tutorial._id; var tutorialIndex = this.props.currentTutorialIndex; - var status = this.props.status.filter(status => status.id === tutorialId)[0]; + var status = this.props.status.filter(status => status._id === tutorialId)[0]; var tasks = status.tasks; var error = tasks.filter(task => task.type === 'error').length > 0; var success = tasks.filter(task => task.type === 'success').length / tasks.length; diff --git a/src/components/Tutorial/StepperVertical.js b/src/components/Tutorial/StepperVertical.js index 1535e20..19387a4 100644 --- a/src/components/Tutorial/StepperVertical.js +++ b/src/components/Tutorial/StepperVertical.js @@ -61,7 +61,7 @@ class StepperVertical extends Component { } componentDidUpdate(props){ - if (props.tutorial.id !== Number(this.props.match.params.tutorialId)) { + if (props.tutorial._id !== this.props.match.params.tutorialId) { this.props.tutorialStep(0); } } @@ -69,7 +69,7 @@ class StepperVertical extends Component { render() { var steps = this.props.steps; var activeStep = this.props.activeStep; - var tutorialStatus = this.props.status.filter(status => status.id === this.props.tutorial.id)[0]; + var tutorialStatus = this.props.status.filter(status => status._id === this.props.tutorial._id)[0]; return (
{steps.map((step, i) => { - var tasksIndex = tutorialStatus.tasks.findIndex(task => task.id === step.id); + var tasksIndex = tutorialStatus.tasks.findIndex(task => task._id === step._id); var taskType = tasksIndex > -1 ? tutorialStatus.tasks[tasksIndex].type : null; var taskStatus = taskType === 'success' ? 'Success' : taskType === 'error' ? 'Error' : 'Other'; return ( -
{this.props.tutorialStep(i)}}> +
{console.log(i); this.props.tutorialStep(i)}}> - + +
diff --git a/src/components/Tutorial/TutorialHome.js b/src/components/Tutorial/TutorialHome.js index 80fd35b..9c7a2ce 100644 --- a/src/components/Tutorial/TutorialHome.js +++ b/src/components/Tutorial/TutorialHome.js @@ -77,14 +77,14 @@ class TutorialHome extends Component {

Tutorial-Übersicht

{this.props.tutorials.map((tutorial, i) => { - var status = this.props.status.filter(status => status.id === tutorial.id)[0]; + var status = this.props.status.filter(status => status._id === tutorial._id)[0]; var tasks = status.tasks; var error = status.tasks.filter(task => task.type === 'error').length > 0; var success = status.tasks.filter(task => task.type === 'success').length / tasks.length var tutorialStatus = success === 1 ? 'Success' : error ? 'Error' : 'Other'; return ( - + {tutorial.title}
diff --git a/src/components/User/Account.js b/src/components/User/Account.js new file mode 100644 index 0000000..09a9545 --- /dev/null +++ b/src/components/User/Account.js @@ -0,0 +1,129 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; + +import Breadcrumbs from '../Breadcrumbs'; +import Alert from '../Alert'; + +import Grid from '@material-ui/core/Grid'; +import List from '@material-ui/core/List'; +import ListItem from '@material-ui/core/ListItem'; +import ListItemIcon from '@material-ui/core/ListItemIcon'; +import ListItemText from '@material-ui/core/ListItemText'; +import Divider from '@material-ui/core/Divider'; +import Paper from '@material-ui/core/Paper'; +import Link from '@material-ui/core/Link'; +import Typography from '@material-ui/core/Typography'; +import Tooltip from '@material-ui/core/Tooltip'; + +import { faUser, faAt, faMapMarkerAlt, faCloudSunRain, faBox } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + + +export class Account extends Component { + + render(){ + const {user} = this.props; + return( +
+ + +

Account

+ + Alle Angaben stammen von openSenseMap und können dort verwaltet werden. + + + + + + + + + + + + + + + + + + + + + + +
+ {this.props.user.boxes.length < 1 ? + + Du hast noch keine senseBox registriert. Besuche openSenseMap um eine senseBox zu registrieren. + + : + Du hast {this.props.user.boxes.length} {this.props.user.boxes.length === 1 ? 'senseBox' : 'senseBoxen'} registriert: + } +
+ + {this.props.user.boxes.map((box, i) => { + var sensors = box.sensors.map(sensor => sensor.title ); + return ( + + + + + + {box.name} + + + + + + + +
+ Modell: + {box.model} +
+
+ + + + + + +
+ Standort: + {`${box.exposure} (lon: ${box.currentLocation.coordinates[0]}, lat: ${box.currentLocation.coordinates[1]})`} +
+
+ + + + + + +
+ Sensoren: + {sensors.join(', ')} +
+
+
+
+ +
+ ) + })} +
+
+ ); + } +} + +Account.propTypes = { + user: PropTypes.object.isRequired +}; + +const mapStateToProps = state => ({ + user: state.auth.user +}); + +export default connect(mapStateToProps, null)(Account); diff --git a/src/components/User/Login.js b/src/components/User/Login.js new file mode 100644 index 0000000..13d4497 --- /dev/null +++ b/src/components/User/Login.js @@ -0,0 +1,168 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { login } from '../../actions/authActions' +import { clearMessages } from '../../actions/messageActions' + +import { withRouter } from 'react-router-dom'; + +import Snackbar from '../Snackbar'; +import Alert from '../Alert'; +import Breadcrumbs from '../Breadcrumbs'; + +import Button from '@material-ui/core/Button'; +import IconButton from '@material-ui/core/IconButton'; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faEye, faEyeSlash } from "@fortawesome/free-solid-svg-icons"; +import TextField from '@material-ui/core/TextField'; +import Divider from '@material-ui/core/Divider'; +import InputAdornment from '@material-ui/core/InputAdornment'; +import CircularProgress from '@material-ui/core/CircularProgress'; +import Link from '@material-ui/core/Link'; + + +export class Login extends Component { + + constructor(props) { + super(props); + this.state = { + redirect: props.location.state ? props.location.state.from.pathname : null, + email: '', + password: '', + snackbar: false, + type: '', + key: '', + message: '', + showPassword: false + }; + } + + componentDidUpdate(props){ + console.log(this.state.redirect); + const { message } = this.props; + if (message !== props.message) { + if(message.id === 'LOGIN_SUCCESS'){ + if(this.state.redirect){ + this.props.history.push(this.state.redirect); + } + else{ + this.props.history.goBack(); + } + } + // Check for login error + else if(message.id === 'LOGIN_FAIL'){ + this.setState({ email: '', password: '', snackbar: true, key: Date.now(), message: 'Der Benutzername oder das Passwort ist nicht korrekt.', type: 'error' }); + } + } + } + + onChange = e => { + this.setState({ [e.target.name]: e.target.value }); + }; + + onSubmit = e => { + e.preventDefault(); + const {email, password} = this.state; + if(email !== '' && password !== ''){ + // create user object + const user = { + email, + password + }; + this.props.login(user); + } else { + this.setState({ snackbar: true, key: Date.now(), message: 'Gib sowohl ein Benutzername als auch ein Passwort ein.', type: 'error' }); + } + }; + + handleClickShowPassword = () => { + this.setState({ showPassword: !this.state.showPassword }); + }; + + handleMouseDownPassword = (e) => { + e.preventDefault(); + }; + + render(){ + return( +
+ + +
+

Anmelden

+ + Zur Anmeldung ist ein Konto auf openSenseMap Voraussetzung. + + + + + + + + + }} + onChange={this.onChange} + fullWidth={true} + /> +

+ +

+

+ Passwort vergessen? +

+ +

+ Du hast noch kein Konto? Registriere dich auf openSenseMap. +

+
+
+ ); + } +} + +Login.propTypes = { + message: PropTypes.object.isRequired, + login: PropTypes.func.isRequired, + clearMessages: PropTypes.func.isRequired, + message: PropTypes.object.isRequired, + progress: PropTypes.bool.isRequired +}; + +const mapStateToProps = state => ({ + message: state.message, + progress: state.auth.progress +}); + +export default connect(mapStateToProps, { login, clearMessages })(withRouter(Login)); diff --git a/src/components/User/MyBadges.js b/src/components/User/MyBadges.js new file mode 100644 index 0000000..4d7dc47 --- /dev/null +++ b/src/components/User/MyBadges.js @@ -0,0 +1,260 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { connectMyBadges, disconnectMyBadges } from '../../actions/authActions'; + +import axios from 'axios'; +import { withRouter } from 'react-router-dom'; + +import Breadcrumbs from '../Breadcrumbs'; +import Alert from '../Alert'; + +import { withStyles } from '@material-ui/core/styles'; +import Paper from '@material-ui/core/Paper'; +import Button from '@material-ui/core/Button'; +import IconButton from '@material-ui/core/IconButton'; +import TextField from '@material-ui/core/TextField'; +import Divider from '@material-ui/core/Divider'; +import InputAdornment from '@material-ui/core/InputAdornment'; +import Link from '@material-ui/core/Link'; +import Typography from '@material-ui/core/Typography'; +import Grid from '@material-ui/core/Grid'; +import Avatar from '@material-ui/core/Avatar'; + +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faEye, faEyeSlash } from "@fortawesome/free-solid-svg-icons"; + +const styles = (theme) => ({ + root: { + '& label.Mui-focused': { + color: '#aed9c8' + }, + '& .MuiOutlinedInput-root': { + '&.Mui-focused fieldset': { + borderColor: '#aed9c8' + }, + borderRadius: '0.75rem' + } + }, + text: { + fontFamily: [ + '"Open Sans"', + 'BlinkMacSystemFont', + '"Segoe UI"', + 'Roboto', + '"Helvetica Neue"', + 'Arial', + 'sans-serif', + '"Apple Color Emoji"', + '"Segoe UI Emoji"', + '"Segoe UI Symbol"', + ].join(','), + fontSize: 16 + } +}); + +export class MyBadges extends Component { + + constructor(props) { + super(props); + this.state = { + username: '', + password: '', + showPassword: false, + msg: '', + badges: [], + progress: false + }; + } + + componentDidMount(){ + if(this.props.user.badge){ + this.getBadges(); + } + } + + componentDidUpdate(props){ + const { message } = this.props; + if (message !== props.message) { + // Check for login error + if(message.id === 'MYBADGES_CONNECT_FAIL'){ + this.setState({msg: 'Der Benutzername oder das Passwort ist nicht korrekt.', username: '', password: '', showPassword: false}); + } + else if(message.id === 'MYBADGES_CONNECT_SUCCESS'){ + this.getBadges(); + } + else if(message.id === 'MYBADGES_DISCONNECT_SUCCESS' || message.id === 'MYBADGES_DISCONNECT_FAIL'){ + this.setState({progress: false}); + } + else { + this.setState({msg: null}); + } + } + } + + getBadges = () => { + this.setState({progress: true}); + axios.get(`${process.env.REACT_APP_BLOCKLY_API}/user/badge`) + .then(res => { + this.setState({badges: res.data.badges, progress: false}); + }) + .catch(err => { + this.setState({progress: false}); + console.log(err); + }); + }; + + onChange = e => { + this.setState({ [e.target.name]: e.target.value, msg: '' }); + }; + + onSubmit = e => { + e.preventDefault(); + const {username, password} = this.state; + // create user object + const user = { + username, + password + }; + this.props.connectMyBadges(user); + }; + + handleClickShowPassword = () => { + this.setState({ showPassword: !this.state.showPassword }); + }; + + handleMouseDownPassword = (e) => { + e.preventDefault(); + }; + + render(){ + return( +
+ + + + + {!this.props.user.badge ? + + Du kannst dein Blockly-Konto mit deinem MyBadges-Konto verknüpfen, um Badges erwerben zu können. + + : null} + +
+
+ My Badges +
+ {!this.props.user.badge ? +
+ {this.state.msg ? +
+ {this.state.msg} +
: null + } + + + + + + + }} + onChange={this.onChange} + fullWidth={true} + /> +

+ +

+

+ Passwort vergessen? +

+ +

+ Du hast noch kein Konto? Registrieren +

+
+ :
+ MyBadges-Konto ist erfolgreich verknüpft. + +
} +
+
+
+ + {this.props.user.badge && !this.state.progress ? + + + {this.state.badges && this.state.badges.length > 0 ? + + Du hast {this.state.badges.length} {this.state.badges.length === 1 ? 'Badge' : 'Badges'} im Kontext Blockly for senseBox erreicht. + + : null} + + + {this.state.badges && this.state.badges.length > 0 ? + this.state.badges.map(badge => ( + + + {badge.image && badge.image.path ? + + : } + +
{badge.name}
+
+
+
+ )) + : + + + Du hast noch keine Badges im Kontext senseBox for Blockly erreicht. + + } +
+
+ : null} +
+
+ ); + } +} + +MyBadges.propTypes = { + connectMyBadges: PropTypes.func.isRequired, + disconnectMyBadges: PropTypes.func.isRequired, + message: PropTypes.object.isRequired, + user: PropTypes.object.isRequired +}; + +const mapStateToProps = state => ({ + message: state.message, + user: state.auth.user +}); + +export default connect(mapStateToProps, { connectMyBadges, disconnectMyBadges })(withStyles(styles, { withTheme: true })(withRouter(MyBadges))); diff --git a/src/components/Compile.js b/src/components/Workspace/Compile.js similarity index 81% rename from src/components/Compile.js rename to src/components/Workspace/Compile.js index 946466f..f8a204a 100644 --- a/src/components/Compile.js +++ b/src/components/Workspace/Compile.js @@ -1,11 +1,11 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; -import { workspaceName } from '../actions/workspaceActions'; +import { workspaceName } from '../../actions/workspaceActions'; -import { detectWhitespacesAndReturnReadableResult } from '../helpers/whitespace'; +import { detectWhitespacesAndReturnReadableResult } from '../../helpers/whitespace'; -import Dialog from './Dialog'; +import Dialog from '../Dialog'; import { withStyles } from '@material-ui/core/styles'; import Button from '@material-ui/core/Button'; @@ -15,7 +15,7 @@ import IconButton from '@material-ui/core/IconButton'; import Tooltip from '@material-ui/core/Tooltip'; import TextField from '@material-ui/core/TextField'; -import { faCogs } from "@fortawesome/free-solid-svg-icons"; +import { faClipboardCheck } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; const styles = (theme) => ({ @@ -23,13 +23,21 @@ const styles = (theme) => ({ zIndex: theme.zIndex.drawer + 1, color: '#fff', }, - button: { - backgroundColor: theme.palette.primary.main, + iconButton: { + backgroundColor: theme.palette.button.compile, color: theme.palette.primary.contrastText, width: '40px', height: '40px', '&:hover': { - backgroundColor: theme.palette.primary.main, + backgroundColor: theme.palette.button.compile, + color: theme.palette.primary.contrastText, + } + }, + button: { + backgroundColor: theme.palette.button.compile, + color: theme.palette.primary.contrastText, + '&:hover': { + backgroundColor: theme.palette.button.compile, color: theme.palette.primary.contrastText, } } @@ -99,7 +107,7 @@ class Compile extends Component { this.download(); } else { - this.setState({ file: true, open: true, title: 'Blöcke kompilieren', content: 'Bitte gib einen Namen für die Bennenung des zu kompilierenden Programms ein und bestätige diesen mit einem Klick auf \'Eingabe\'.' }); + this.setState({ file: true, open: true, title: 'Projekt kompilieren', content: 'Bitte gib einen Namen für die Bennenung des zu kompilierenden Programms ein und bestätige diesen mit einem Klick auf \'Eingabe\'.' }); } } @@ -111,17 +119,17 @@ class Compile extends Component { return (
{this.props.iconButton ? - + this.compile()} > - + : - } diff --git a/src/components/Workspace/DeleteProject.js b/src/components/Workspace/DeleteProject.js new file mode 100644 index 0000000..3df813d --- /dev/null +++ b/src/components/Workspace/DeleteProject.js @@ -0,0 +1,89 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { deleteProject } from '../../actions/projectActions'; + +import { withRouter } from 'react-router-dom'; + +import Snackbar from '../Snackbar'; + +import { withStyles } from '@material-ui/core/styles'; +import IconButton from '@material-ui/core/IconButton'; +import Tooltip from '@material-ui/core/Tooltip'; + +import { faTrashAlt } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +const styles = (theme) => ({ + buttonTrash: { + backgroundColor: theme.palette.error.dark, + color: theme.palette.primary.contrastText, + width: '40px', + height: '40px', + '&:hover': { + backgroundColor: theme.palette.error.dark, + color: theme.palette.primary.contrastText, + } + } +}); + + + +class DeleteProject extends Component { + + constructor(props) { + super(props); + this.inputRef = React.createRef(); + this.state = { + snackbar: false, + type: '', + key: '', + message: '' + }; + } + + componentDidUpdate(props) { + if(this.props.message !== props.message){ + if(this.props.message.id === 'PROJECT_DELETE_SUCCESS'){ + this.props.history.push(`/${this.props.projectType}`); + } + else if(this.props.message.id === 'PROJECT_DELETE_FAIL'){ + this.setState({ snackbar: true, key: Date.now(), message: `Fehler beim Löschen des Projektes. Versuche es noch einmal.`, type: 'error' }); + } + } + } + + render() { + return ( +
+ + this.props.deleteProject(this.props.projectType, this.props.project._id)} + > + + + + + +
+ ); + }; +} + +DeleteProject.propTypes = { + deleteProject: PropTypes.func.isRequired, + message: PropTypes.string.isRequired +}; + +const mapStateToProps = state => ({ + message: state.message, +}); + + +export default connect(mapStateToProps, { deleteProject })(withStyles(styles, { withTheme: true })(withRouter(DeleteProject))); diff --git a/src/components/Workspace/DownloadProject.js b/src/components/Workspace/DownloadProject.js new file mode 100644 index 0000000..9b30273 --- /dev/null +++ b/src/components/Workspace/DownloadProject.js @@ -0,0 +1,66 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; + +import { saveAs } from 'file-saver'; + +import { detectWhitespacesAndReturnReadableResult } from '../../helpers/whitespace'; + +import { withStyles } from '@material-ui/core/styles'; +import IconButton from '@material-ui/core/IconButton'; +import Tooltip from '@material-ui/core/Tooltip'; + +import { faFileDownload } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +const styles = (theme) => ({ + button: { + backgroundColor: theme.palette.primary.main, + color: theme.palette.primary.contrastText, + width: '40px', + height: '40px', + '&:hover': { + backgroundColor: theme.palette.primary.main, + color: theme.palette.primary.contrastText, + } + } +}); + + +class DownloadProject extends Component { + + downloadXmlFile = () => { + var code = this.props.xml; + var fileName = detectWhitespacesAndReturnReadableResult(this.props.name); + fileName = `${fileName}.xml` + var blob = new Blob([code], { type: 'text/xml' }); + saveAs(blob, fileName); + } + + render() { + return ( +
+ + this.downloadXmlFile()} + > + + + +
+ ); + }; +} + +DownloadProject.propTypes = { + xml: PropTypes.string.isRequired, + name: PropTypes.string.isRequired +}; + +const mapStateToProps = state => ({ + xml: state.workspace.code.xml, + name: state.workspace.name +}); + +export default connect(mapStateToProps, null)(withStyles(styles, { withTheme: true })(DownloadProject)); diff --git a/src/components/Workspace/OpenProject.js b/src/components/Workspace/OpenProject.js new file mode 100644 index 0000000..6038a49 --- /dev/null +++ b/src/components/Workspace/OpenProject.js @@ -0,0 +1,143 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { clearStats, workspaceName } from '../../actions/workspaceActions'; + +import * as Blockly from 'blockly/core'; + +import Snackbar from '../Snackbar'; +import Dialog from '../Dialog'; + +import { withStyles } from '@material-ui/core/styles'; +import Button from '@material-ui/core/Button'; +import IconButton from '@material-ui/core/IconButton'; +import Tooltip from '@material-ui/core/Tooltip'; +import Typography from '@material-ui/core/Typography'; + +import { faUpload } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +const styles = (theme) => ({ + button: { + backgroundColor: theme.palette.primary.main, + color: theme.palette.primary.contrastText, + width: '40px', + height: '40px', + '&:hover': { + backgroundColor: theme.palette.primary.main, + color: theme.palette.primary.contrastText, + } + } +}); + + +class OpenProject extends Component { + + constructor(props) { + super(props); + this.inputRef = React.createRef(); + this.state = { + title: '', + content: '', + open: false, + snackbar: false, + type: '', + key: '', + message: '' + }; + } + + toggleDialog = () => { + this.setState({ open: !this.state, title: '', content: '' }); + } + + uploadXmlFile = (xmlFile) => { + if (xmlFile.type !== 'text/xml') { + this.setState({ open: true, title: 'Unzulässiger Dateityp', content: 'Die übergebene Datei entsprach nicht dem geforderten Format. Es sind nur XML-Dateien zulässig.' }); + } + else { + var reader = new FileReader(); + reader.readAsText(xmlFile); + reader.onloadend = () => { + var xmlDom = null; + try { + xmlDom = Blockly.Xml.textToDom(reader.result); + const workspace = Blockly.getMainWorkspace(); + var xmlBefore = this.props.xml; + workspace.clear(); + this.props.clearStats(); + Blockly.Xml.domToWorkspace(xmlDom, workspace); + if (workspace.getAllBlocks().length < 1) { + Blockly.Xml.domToWorkspace(Blockly.Xml.textToDom(xmlBefore), workspace) + this.setState({ open: true, title: 'Keine Blöcke', content: 'Es wurden keine Blöcke detektiert. Bitte überprüfe den XML-Code und versuche es erneut.' }); + } + else { + if (!this.props.assessment) { + var extensionPosition = xmlFile.name.lastIndexOf('.'); + this.props.workspaceName(xmlFile.name.substr(0, extensionPosition)); + } + this.setState({ snackbar: true, type: 'success', key: Date.now(), message: 'Das Projekt aus gegebener XML-Datei wurde erfolgreich eingefügt.' }); + } + } catch (err) { + this.setState({ open: true, title: 'Ungültige XML', content: 'Die XML-Datei konnte nicht in Blöcke zerlegt werden. Bitte überprüfe den XML-Code und versuche es erneut.' }); + } + }; + } + } + + render() { + return ( +
+
+ { this.uploadXmlFile(e.target.files[0]) }} + id="open-blocks" + type="file" + /> + +
+ + + +
+ ); + }; +} + +OpenProject.propTypes = { + clearStats: PropTypes.func.isRequired, + workspaceName: PropTypes.func.isRequired, + xml: PropTypes.string.isRequired, + name: PropTypes.string.isRequired +}; + +const mapStateToProps = state => ({ + xml: state.workspace.code.xml, + name: state.workspace.name +}); + +export default connect(mapStateToProps, { clearStats, workspaceName })(withStyles(styles, { withTheme: true })(OpenProject)); diff --git a/src/components/Workspace/ResetWorkspace.js b/src/components/Workspace/ResetWorkspace.js new file mode 100644 index 0000000..0c36299 --- /dev/null +++ b/src/components/Workspace/ResetWorkspace.js @@ -0,0 +1,95 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { clearStats, onChangeCode, workspaceName } from '../../actions/workspaceActions'; + +import * as Blockly from 'blockly/core'; + +import { createNameId } from 'mnemonic-id'; +import { initialXml } from '../Blockly/initialXml.js'; + +import Snackbar from '../Snackbar'; + +import { withStyles } from '@material-ui/core/styles'; +import IconButton from '@material-ui/core/IconButton'; +import Tooltip from '@material-ui/core/Tooltip'; + +import { faShare } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +const styles = (theme) => ({ + button: { + backgroundColor: theme.palette.primary.main, + color: theme.palette.primary.contrastText, + width: '40px', + height: '40px', + '&:hover': { + backgroundColor: theme.palette.primary.main, + color: theme.palette.primary.contrastText, + } + } +}); + + + +class ResetWorkspace extends Component { + + constructor(props) { + super(props); + this.inputRef = React.createRef(); + this.state = { + snackbar: false, + type: '', + key: '', + message: '', + }; + } + + resetWorkspace = () => { + const workspace = Blockly.getMainWorkspace(); + Blockly.Events.disable(); // https://groups.google.com/forum/#!topic/blockly/m7e3g0TC75Y + // if events are disabled, then the workspace will be cleared AND the blocks are not in the trashcan + const xmlDom = Blockly.Xml.textToDom(initialXml) + Blockly.Xml.clearWorkspaceAndLoadFromXml(xmlDom, workspace); + Blockly.Events.enable(); + workspace.options.maxBlocks = Infinity; + this.props.onChangeCode(); + this.props.clearStats(); + if (!this.props.assessment) { + this.props.workspaceName(createNameId()); + } + this.setState({ snackbar: true, type: 'success', key: Date.now(), message: 'Das Projekt wurde erfolgreich zurückgesetzt.' }); + } + + + + render() { + return ( +
+ + this.resetWorkspace()} + > + + + + + +
+ ); + }; +} + +ResetWorkspace.propTypes = { + clearStats: PropTypes.func.isRequired, + onChangeCode: PropTypes.func.isRequired, + workspaceName: PropTypes.func.isRequired +}; + +export default connect(null, { clearStats, onChangeCode, workspaceName })(withStyles(styles, { withTheme: true })(ResetWorkspace)); diff --git a/src/components/Workspace/SaveProject.js b/src/components/Workspace/SaveProject.js new file mode 100644 index 0000000..0a46cf0 --- /dev/null +++ b/src/components/Workspace/SaveProject.js @@ -0,0 +1,200 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { updateProject, setDescription } from '../../actions/projectActions'; + +import axios from 'axios'; +import { withRouter } from 'react-router-dom'; + +import Snackbar from '../Snackbar'; +import Dialog from '../Dialog'; + +import { withStyles } from '@material-ui/core/styles'; +import Button from '@material-ui/core/Button'; +import IconButton from '@material-ui/core/IconButton'; +import Tooltip from '@material-ui/core/Tooltip'; +import TextField from '@material-ui/core/TextField'; +import Typography from '@material-ui/core/Typography'; +import Menu from '@material-ui/core/Menu'; +import MenuItem from '@material-ui/core/MenuItem'; + +import { faSave } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +const styles = (theme) => ({ + button: { + backgroundColor: theme.palette.primary.main, + color: theme.palette.primary.contrastText, + width: '40px', + height: '40px', + '&:hover': { + backgroundColor: theme.palette.primary.main, + color: theme.palette.primary.contrastText, + } + } +}); + + + +class SaveProject extends Component { + + constructor(props) { + super(props); + this.inputRef = React.createRef(); + this.state = { + title: '', + content: '', + open: false, + description: props.description, + snackbar: false, + type: '', + key: '', + message: '', + menuOpen: false, + anchor: '', + projectType: props.projectType + }; + } + + componentDidUpdate(props) { + if (props.projectType !== this.props.projectType) { + this.setState({ projectType: this.props.projectType }); + } + if (props.description !== this.props.description) { + this.setState({ description: this.props.description }); + } + if(this.props.message !== props.message){ + if(this.props.message.id === 'PROJECT_UPDATE_SUCCESS'){ + this.setState({ snackbar: true, key: Date.now(), message: `Das Projekt wurde erfolgreich aktualisiert.`, type: 'success' }); + } + else if(this.props.message.id === 'GALLERY_UPDATE_SUCCESS'){ + this.setState({ snackbar: true, key: Date.now(), message: `Das Galerie-Projekt wurde erfolgreich aktualisiert.`, type: 'success' }); + } + else if(this.props.message.id === 'PROJECT_UPDATE_FAIL'){ + this.setState({ snackbar: true, key: Date.now(), message: `Fehler beim Aktualisieren des Projektes. Versuche es noch einmal.`, type: 'error' }); + } + else if(this.props.message.id === 'GALLERY_UPDATE_FAIL'){ + this.setState({ snackbar: true, key: Date.now(), message: `Fehler beim Aktualisieren des Galerie-Projektes. Versuche es noch einmal.`, type: 'error' }); + } + } + } + + toggleMenu = (e) => { + this.setState({ menuOpen: !this.state.menuOpen, anchor: e.currentTarget }); + }; + + toggleDialog = () => { + this.setState({ open: !this.state, title: '', content: '' }); + } + + saveProject = () => { + var body = { + xml: this.props.xml, + title: this.props.name + }; + if(this.state.projectType === 'gallery'){ + body.description = this.state.description; + } + axios.post(`${process.env.REACT_APP_BLOCKLY_API}/${this.state.projectType}`, body) + .then(res => { + var project = res.data[this.state.projectType]; + this.props.history.push(`/${this.state.projectType}/${project._id}`); + }) + .catch(err => { + this.setState({ snackbar: true, key: Date.now(), message: `Fehler beim Speichern des ${this.state.projectType === 'gallery' ? 'Galerie-':''}Projektes. Versuche es noch einmal.`, type: 'error' }); + window.scrollTo(0, 0); + }); + } + + setDescription = (e) => { + this.setState({ description: e.target.value }); + } + + workspaceDescription = () => { + this.props.setDescription(this.state.description); + this.setState({projectType: 'gallery'}, + () => this.saveProject() + ); + } + + render() { + console.log(1, this.props); + return ( +
+ + this.toggleMenu(e) : this.state.projectType === 'project' ? () => this.props.updateProject(this.state.projectType, this.props.project._id) : () => {this.setState({projectType: 'project'}, () => this.saveProject())}} + > + + + + + {this.toggleMenu(e); this.props.updateProject(this.state.projectType, this.props.project._id)} : (e) => {this.toggleMenu(e); this.setState({projectType: 'project'}, () => this.saveProject())}} + > + {this.state.projectType === 'project' ? 'Projekt aktualisieren' : 'Projekt erstellen'} + + {this.toggleMenu(e); this.props.updateProject(this.state.projectType, this.props.project._id)} : (e) => {this.toggleMenu(e); this.setState({ open: true, title: 'Projekbeschreibung ergänzen', content: 'Bitte gib eine Beschreibung für das Galerie-Projekt ein und bestätige deine Angabe mit einem Klick auf \'Eingabe\'.'});}} + > + {this.state.projectType === 'gallery' ? 'Galerie-Projekt aktualisieren' : 'Galerie-Projekt erstellen'} + + + + + {this.toggleDialog(); this.setState({ description: this.props.description });}} + onClick={() => {this.toggleDialog(); this.setState({ description: this.props.description });}} + button={'Abbrechen'} + > +
+ + +
+
+
+ ); + }; +} + +SaveProject.propTypes = { + updateProject: PropTypes.func.isRequired, + setDescription: PropTypes.func.isRequired, + name: PropTypes.string.isRequired, + description: PropTypes.string.isRequired, + xml: PropTypes.string.isRequired, + message: PropTypes.object.isRequired, + user: PropTypes.object +}; + +const mapStateToProps = state => ({ + name: state.workspace.name, + description: state.project.description, + xml: state.workspace.code.xml, + message: state.message, + user: state.auth.user +}); + +export default connect(mapStateToProps, { updateProject, setDescription })(withStyles(styles, { withTheme: true })(withRouter(SaveProject))); diff --git a/src/components/Workspace/Screenshot.js b/src/components/Workspace/Screenshot.js new file mode 100644 index 0000000..c150db2 --- /dev/null +++ b/src/components/Workspace/Screenshot.js @@ -0,0 +1,97 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; + +import * as Blockly from 'blockly/core'; + +import { saveAs } from 'file-saver'; + +import { detectWhitespacesAndReturnReadableResult } from '../../helpers/whitespace'; + +import { withStyles } from '@material-ui/core/styles'; +import IconButton from '@material-ui/core/IconButton'; +import Tooltip from '@material-ui/core/Tooltip'; + +import { faCamera } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +const styles = (theme) => ({ + button: { + backgroundColor: theme.palette.primary.main, + color: theme.palette.primary.contrastText, + width: '40px', + height: '40px', + '&:hover': { + backgroundColor: theme.palette.primary.main, + color: theme.palette.primary.contrastText, + } + } +}); + + +class Screenshot extends Component { + + getSvg = () => { + const workspace = Blockly.getMainWorkspace(); + var canvas = workspace.svgBlockCanvas_.cloneNode(true); + + if (canvas.children[0] !== undefined) { + canvas.removeAttribute("transform"); + // does not work in react + // var cssContent = Blockly.Css.CONTENT.join(''); + var cssContent = ''; + for (var i = 0; i < document.getElementsByTagName('style').length; i++) { + if (/^blockly.*$/.test(document.getElementsByTagName('style')[i].id)) { + cssContent += document.getElementsByTagName('style')[i].firstChild.data.replace(/\..* \./g, '.'); + } + } + // ensure that fill-opacity is 1, because there cannot be a replacing + // https://github.com/google/blockly/pull/3431/files#diff-00254795773903d3c0430915a68c9521R328 + cssContent += `.blocklyPath { + fill-opacity: 1; + } + .blocklyPathDark { + display: flex; + } + .blocklyPathLight { + display: flex; + } `; + var css = ''; + var bbox = document.getElementsByClassName("blocklyBlockCanvas")[0].getBBox(); + var content = new XMLSerializer().serializeToString(canvas); + var xml = ` + ${css}">${content}`; + var fileName = detectWhitespacesAndReturnReadableResult(this.props.name); + // this.props.workspaceName(this.state.name); + fileName = `${fileName}.svg` + var blob = new Blob([xml], { type: 'image/svg+xml;base64' }); + saveAs(blob, fileName); + } + } + + render() { + return ( +
+ + this.getSvg()} + > + + + +
+ ); + }; +} + +Screenshot.propTypes = { + name: PropTypes.string.isRequired, +}; + +const mapStateToProps = state => ({ + name: state.workspace.name, +}); + +export default connect(mapStateToProps, null)(withStyles(styles, { withTheme: true })(Screenshot)); diff --git a/src/components/Workspace/ShareProject.js b/src/components/Workspace/ShareProject.js new file mode 100644 index 0000000..060e0e9 --- /dev/null +++ b/src/components/Workspace/ShareProject.js @@ -0,0 +1,157 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { shareProject } from '../../actions/projectActions'; +import { clearMessages } from '../../actions/messageActions'; + +import moment from 'moment'; + +import Dialog from '../Dialog'; +import Snackbar from '../Snackbar'; + +import { Link } from 'react-router-dom'; + +import { withStyles } from '@material-ui/core/styles'; +import IconButton from '@material-ui/core/IconButton'; +import Tooltip from '@material-ui/core/Tooltip'; +import Typography from '@material-ui/core/Typography'; + +import { faShareAlt, faCopy } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +const styles = (theme) => ({ + button: { + backgroundColor: theme.palette.primary.main, + color: theme.palette.primary.contrastText, + width: '40px', + height: '40px', + '&:hover': { + backgroundColor: theme.palette.primary.main, + color: theme.palette.primary.contrastText, + } + }, + link: { + color: theme.palette.primary.main, + textDecoration: 'none', + '&:hover': { + color: theme.palette.primary.main, + textDecoration: 'underline' + } + } +}); + + +class WorkspaceFunc extends Component { + + constructor(props) { + super(props); + this.inputRef = React.createRef(); + this.state = { + snackbar: false, + type: '', + key: '', + message: '', + title: '', + content: '', + open: false, + id: '', + }; + } + + componentDidUpdate(props) { + if(this.props.message !== props.message){ + if(this.props.message.id === 'SHARE_SUCCESS' && (!this.props.multiple || this.props.message.status === this.props.project._id)){ + this.setState({ share: true, open: true, title: 'Programm teilen', id: this.props.message.status }); + } + else if(this.props.message.id === 'SHARE_FAIL' && (!this.props.multiple || this.props.message.status === this.props.project._id)){ + this.setState({ snackbar: true, key: Date.now(), message: `Fehler beim Erstellen eines Links zum Teilen deines Programmes. Versuche es noch einmal.`, type: 'error' }); + window.scrollTo(0, 0); + } + } + } + + componentWillUnmount(){ + this.props.clearMessages(); + } + + toggleDialog = () => { + this.setState({ open: !this.state, title: '', content: '' }); + } + + shareBlocks = () => { + if(this.props.projectType === 'project' && this.props.project.shared){ + // project is already shared + this.setState({ open: true, title: 'Programm teilen', id: this.props.project._id }); + } + else { + this.props.shareProject(this.props.name || this.props.project.title, this.props.projectType, this.props.project ? this.props.project._id : undefined); + } + } + + render() { + return ( +
+ + this.shareBlocks()} + > + + + + + + +
+ Über den folgenden Link kannst du dein Programm teilen: + this.toggleDialog()} className={this.props.classes.link}>{`${window.location.origin}/share/${this.state.id}`} + + { + navigator.clipboard.writeText(`${window.location.origin}/share/${this.state.id}`); + this.setState({ snackbar: true, key: Date.now(), message: 'Link erfolgreich in Zwischenablage gespeichert.', type: 'success' }); + }} + > + + + + {this.props.project && this.props.project.shared && this.props.message.id !== 'SHARE_SUCCESS' ? + {`Das Projekt wurde bereits geteilt. Der Link ist noch mindestens ${ + moment(this.props.project.shared).diff(moment().utc(), 'days') === 0 ? + moment(this.props.project.shared).diff(moment().utc(), 'hours') === 0 ? + `${moment(this.props.project.shared).diff(moment().utc(), 'minutes')} Minuten` + : `${moment(this.props.project.shared).diff(moment().utc(), 'hours')} Stunden` + : `${moment(this.props.project.shared).diff(moment().utc(), 'days')} Tage`} gültig.`} + : {`Der Link ist nun ${process.env.REACT_APP_SHARE_LINK_EXPIRES} Tage gültig.`}} +
+
+
+ ); + }; +} + +WorkspaceFunc.propTypes = { + shareProject: PropTypes.func.isRequired, + clearMessages: PropTypes.func.isRequired, + name: PropTypes.string.isRequired, + message: PropTypes.object.isRequired +}; + +const mapStateToProps = state => ({ + name: state.workspace.name, + message: state.message +}); + +export default connect(mapStateToProps, { shareProject, clearMessages })(withStyles(styles, { withTheme: true })(WorkspaceFunc)); diff --git a/src/components/TrashcanButtons.js b/src/components/Workspace/TrashcanButtons.js similarity index 100% rename from src/components/TrashcanButtons.js rename to src/components/Workspace/TrashcanButtons.js diff --git a/src/components/Workspace/WorkspaceFunc.js b/src/components/Workspace/WorkspaceFunc.js new file mode 100644 index 0000000..8c8ae31 --- /dev/null +++ b/src/components/Workspace/WorkspaceFunc.js @@ -0,0 +1,94 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; + +import WorkspaceName from './WorkspaceName'; +import SaveProject from './SaveProject'; +import Compile from './Compile'; +import SolutionCheck from '../Tutorial/SolutionCheck'; +import DownloadProject from './DownloadProject'; +import OpenProject from './OpenProject'; +import Screenshot from './Screenshot'; +import ShareProject from './ShareProject'; +import ResetWorkspace from './ResetWorkspace'; +import DeleteProject from './DeleteProject'; + +class WorkspaceFunc extends Component { + + render() { + return ( +
+ + {!this.props.assessment ? + + : null} + + {this.props.assessment ? + + : !this.props.multiple ? + + : null} + + {this.props.user && !this.props.multiple? + + : null} + + {!this.props.multiple ? + + : null} + + {!this.props.assessment && !this.props.multiple? + + : null} + + {!this.props.assessment && !this.props.multiple? + + : null} + + {this.props.projectType !== 'gallery' && !this.props.assessment ? + + :null} + + {!this.props.multiple ? + + : null} + + {!this.props.assessment && (this.props.projectType === 'project' || this.props.projectType === 'gallery') && this.props.user && this.props.user.email === this.props.project.creator ? + + :null} + +
+ ); + }; +} + +WorkspaceFunc.propTypes = { + user: PropTypes.object +}; + +const mapStateToProps = state => ({ + user: state.auth.user +}); + +export default connect(mapStateToProps, null)(WorkspaceFunc); diff --git a/src/components/Workspace/WorkspaceName.js b/src/components/Workspace/WorkspaceName.js new file mode 100644 index 0000000..9efe915 --- /dev/null +++ b/src/components/Workspace/WorkspaceName.js @@ -0,0 +1,150 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { workspaceName } from '../../actions/workspaceActions'; +import { setDescription, updateProject } from '../../actions/projectActions'; + +import Snackbar from '../Snackbar'; +import Dialog from '../Dialog'; + +import withWidth, { isWidthDown } from '@material-ui/core/withWidth'; +import { withStyles } from '@material-ui/core/styles'; +import Button from '@material-ui/core/Button'; +import Tooltip from '@material-ui/core/Tooltip'; +import TextField from '@material-ui/core/TextField'; +import Typography from '@material-ui/core/Typography'; + +import { faPen } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +const styles = (theme) => ({ + workspaceName: { + backgroundColor: theme.palette.secondary.main, + borderRadius: '25px', + display: 'inline-flex', + cursor: 'pointer', + '&:hover': { + color: theme.palette.primary.main, + } + } +}); + + +class WorkspaceName extends Component { + + constructor(props) { + super(props); + this.inputRef = React.createRef(); + this.state = { + title: '', + content: '', + open: false, + name: props.name, + description: props.description, + snackbar: false, + type: '', + key: '', + message: '' + }; + } + + componentDidUpdate(props) { + if (props.name !== this.props.name) { + this.setState({ name: this.props.name }); + } + if (props.description !== this.props.description) { + this.setState({ description: this.props.description }); + } + } + + toggleDialog = () => { + this.setState({ open: !this.state, title: '', content: '' }); + } + + setFileName = (e) => { + this.setState({ name: e.target.value }); + } + + setDescription = (e) => { + this.setState({ description: e.target.value }); + } + + renameWorkspace = () => { + this.props.workspaceName(this.state.name); + this.toggleDialog(); + if(this.props.projectType === 'project' || this.props.projectType === 'gallery' || this.state.projectType === 'gallery'){ + if(this.props.projectType === 'gallery' || this.state.projectType === 'gallery'){ + this.props.setDescription(this.state.description); + } + if(this.state.projectType === 'gallery'){ + this.saveGallery(); + } else { + this.props.updateProject(this.props.projectType, this.props.project._id); + } + } else { + this.setState({ snackbar: true, type: 'success', key: Date.now(), message: `Das Projekt wurde erfolgreich in '${this.state.name}' umbenannt.` }); + } + } + + render() { + return ( +
+ +
{if(this.props.multiple){this.props.workspaceName(this.props.project.title);if(this.props.projectType === 'gallery'){this.props.setDescription(this.props.project.description);}} this.setState({ open: true, title: this.props.projectType === 'gallery' ? 'Projektdaten ändern': this.props.projectType === 'project' ? 'Projekt umbenennen' : 'Projekt benennen', content: this.props.projectType === 'gallery' ? 'Bitte gib einen Titel und eine Beschreibung für das Galerie-Projekt ein und bestätige die Angaben mit einem Klick auf \'Eingabe\'.':'Bitte gib einen Namen für das Projekt ein und bestätige diesen mit einem Klick auf \'Eingabe\'.' }) }} + > + {this.props.name && !isWidthDown(this.props.projectType === 'project' || this.props.projectType === 'gallery' ? 'xl':'xs', this.props.width) ? + {this.props.name} + : null} +
+ +
+
+
+ + + {this.toggleDialog(); this.setState({ name: this.props.name, description: this.props.description });}} + onClick={() => {this.toggleDialog(); this.setState({ name: this.props.name, description: this.props.description });}} + button={'Abbrechen'} + > +
+ {this.props.projectType === 'gallery' || this.state.projectType === 'gallery' ? +
+ + +
+ : } + +
+
+
+ ); + }; +} + +WorkspaceName.propTypes = { + workspaceName: PropTypes.func.isRequired, + setDescription: PropTypes.func.isRequired, + updateProject: PropTypes.func.isRequired, + name: PropTypes.string.isRequired, + description: PropTypes.string.isRequired, + message: PropTypes.object.isRequired, +}; + +const mapStateToProps = state => ({ + name: state.workspace.name, + description: state.project.description, + message: state.message, +}); + +export default connect(mapStateToProps, { workspaceName, setDescription, updateProject })(withStyles(styles, { withTheme: true })(withWidth()(WorkspaceName))); diff --git a/src/components/WorkspaceStats.js b/src/components/Workspace/WorkspaceStats.js similarity index 100% rename from src/components/WorkspaceStats.js rename to src/components/Workspace/WorkspaceStats.js diff --git a/src/components/WorkspaceFunc.js b/src/components/WorkspaceFunc.js deleted file mode 100644 index bf6886c..0000000 --- a/src/components/WorkspaceFunc.js +++ /dev/null @@ -1,395 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { connect } from 'react-redux'; -import { clearStats, onChangeCode, workspaceName } from '../actions/workspaceActions'; - -import * as Blockly from 'blockly/core'; - -import { saveAs } from 'file-saver'; - -import { detectWhitespacesAndReturnReadableResult } from '../helpers/whitespace'; -import { initialXml } from './Blockly/initialXml.js'; - -import Compile from './Compile'; -import SolutionCheck from './Tutorial/SolutionCheck'; -import Snackbar from './Snackbar'; - -import withWidth, { isWidthDown } from '@material-ui/core/withWidth'; -import { withStyles } from '@material-ui/core/styles'; -import Button from '@material-ui/core/Button'; -import IconButton from '@material-ui/core/IconButton'; -import Tooltip from '@material-ui/core/Tooltip'; -import TextField from '@material-ui/core/TextField'; -import Typography from '@material-ui/core/Typography'; -import { createId } from 'mnemonic-id'; - - -import Dialog from './Dialog'; -// import Dialog from '@material-ui/core/Dialog'; -import DialogActions from '@material-ui/core/DialogActions'; -import DialogContent from '@material-ui/core/DialogContent'; -import DialogContentText from '@material-ui/core/DialogContentText'; -import DialogTitle from '@material-ui/core/DialogTitle'; - - - -import { faPen, faSave, faUpload, faCamera, faShare, faShareAlt } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; - -const styles = (theme) => ({ - button: { - backgroundColor: theme.palette.primary.main, - color: theme.palette.primary.contrastText, - width: '40px', - height: '40px', - '&:hover': { - backgroundColor: theme.palette.primary.main, - color: theme.palette.primary.contrastText, - } - }, - workspaceName: { - backgroundColor: theme.palette.secondary.main, - borderRadius: '25px', - display: 'inline-flex', - cursor: 'pointer', - '&:hover': { - color: theme.palette.primary.main, - } - } -}); - - - -class WorkspaceFunc extends Component { - - constructor(props) { - super(props); - this.inputRef = React.createRef(); - this.state = { - title: '', - content: '', - open: false, - file: false, - saveFile: false, - share: false, - name: props.name, - snackbar: false, - key: '', - message: '', - id: '' - }; - } - - - - componentDidUpdate(props) { - if (props.name !== this.props.name) { - this.setState({ name: this.props.name }); - } - } - - toggleDialog = () => { - this.setState({ open: !this.state, share: false }); - } - - saveXmlFile = () => { - var code = this.props.xml; - this.toggleDialog(); - var fileName = detectWhitespacesAndReturnReadableResult(this.state.name); - this.props.workspaceName(this.state.name); - fileName = `${fileName}.xml` - var blob = new Blob([code], { type: 'text/xml' }); - saveAs(blob, fileName); - } - - shareBlocks = () => { - let code = this.props.xml; - let requestOptions = ''; - let id = ''; - if (this.state.id !== '') { - requestOptions = { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - id: this.state.id, - name: this.state.name, - xml: code - }) - }; - fetch(process.env.REACT_APP_BLOCKLY_API + '/share' + this.state.id, requestOptions) - .then(response => response.json()) - .then(data => this.setState({ share: true })); - } - else { - id = createId(10); - requestOptions = { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - id: id, - name: this.state.name, - xml: code - }) - }; - fetch(process.env.REACT_APP_BLOCKLY_API + '/share', requestOptions) - .then(response => response.json()) - .then(data => this.setState({ id: data.id, share: true })); - } - } - - getSvg = () => { - const workspace = Blockly.getMainWorkspace(); - var canvas = workspace.svgBlockCanvas_.cloneNode(true); - - if (canvas.children[0] !== undefined) { - canvas.removeAttribute("transform"); - - // does not work in react - // var cssContent = Blockly.Css.CONTENT.join(''); - var cssContent = ''; - for (var i = 0; i < document.getElementsByTagName('style').length; i++) { - if (/^blockly.*$/.test(document.getElementsByTagName('style')[i].id)) { - cssContent += document.getElementsByTagName('style')[i].firstChild.data.replace(/\..* \./g, '.'); - } - } - // ensure that fill-opacity is 1, because there cannot be a replacing - // https://github.com/google/blockly/pull/3431/files#diff-00254795773903d3c0430915a68c9521R328 - cssContent += `.blocklyPath { - fill-opacity: 1; - } - .blocklyPathDark { - display: flex; - } - .blocklyPathLight { - display: flex; - } `; - - var css = ''; - - var bbox = document.getElementsByClassName("blocklyBlockCanvas")[0].getBBox(); - var content = new XMLSerializer().serializeToString(canvas); - - var xml = ` - ${css}">${content}`; - var fileName = detectWhitespacesAndReturnReadableResult(this.state.name); - this.props.workspaceName(this.state.name); - fileName = `${fileName}.svg` - var blob = new Blob([xml], { type: 'image/svg+xml;base64' }); - saveAs(blob, fileName); - } - } - - createFileName = (filetype) => { - this.setState({ file: filetype }, () => { - if (this.state.name) { - this.state.file === 'xml' ? this.saveXmlFile() : this.getSvg() - } - else { - this.setState({ saveFile: true, file: filetype, open: true, title: this.state.file === 'xml' ? 'Blöcke speichern' : 'Screenshot erstellen', content: `Bitte gib einen Namen für die Bennenung der ${this.state.file === 'xml' ? 'XML' : 'SVG'}-Datei ein und bestätige diesen mit einem Klick auf 'Eingabe'.` }); - } - }); - } - - setFileName = (e) => { - this.setState({ name: e.target.value }); - } - - uploadXmlFile = (xmlFile) => { - if (xmlFile.type !== 'text/xml') { - this.setState({ open: true, file: false, title: 'Unzulässiger Dateityp', content: 'Die übergebene Datei entsprach nicht dem geforderten Format. Es sind nur XML-Dateien zulässig.' }); - } - else { - var reader = new FileReader(); - reader.readAsText(xmlFile); - reader.onloadend = () => { - var xmlDom = null; - try { - xmlDom = Blockly.Xml.textToDom(reader.result); - const workspace = Blockly.getMainWorkspace(); - var xmlBefore = this.props.xml; - workspace.clear(); - this.props.clearStats(); - Blockly.Xml.domToWorkspace(xmlDom, workspace); - if (workspace.getAllBlocks().length < 1) { - Blockly.Xml.domToWorkspace(Blockly.Xml.textToDom(xmlBefore), workspace) - this.setState({ open: true, file: false, title: 'Keine Blöcke', content: 'Es wurden keine Blöcke detektiert. Bitte überprüfe den XML-Code und versuche es erneut.' }); - } - else { - if (!this.props.assessment) { - var extensionPosition = xmlFile.name.lastIndexOf('.'); - this.props.workspaceName(xmlFile.name.substr(0, extensionPosition)); - } - this.setState({ snackbar: true, key: Date.now(), message: 'Das Projekt aus gegebener XML-Datei wurde erfolgreich eingefügt.' }); - } - } catch (err) { - this.setState({ open: true, file: false, title: 'Ungültige XML', content: 'Die XML-Datei konnte nicht in Blöcke zerlegt werden. Bitte überprüfe den XML-Code und versuche es erneut.' }); - } - }; - } - } - - renameWorkspace = () => { - this.props.workspaceName(this.state.name); - this.toggleDialog(); - this.setState({ snackbar: true, key: Date.now(), message: `Das Projekt wurde erfolgreich in '${this.state.name}' umbenannt.` }); - } - - resetWorkspace = () => { - const workspace = Blockly.getMainWorkspace(); - Blockly.Events.disable(); // https://groups.google.com/forum/#!topic/blockly/m7e3g0TC75Y - // if events are disabled, then the workspace will be cleared AND the blocks are not in the trashcan - const xmlDom = Blockly.Xml.textToDom(initialXml) - Blockly.Xml.clearWorkspaceAndLoadFromXml(xmlDom, workspace); - Blockly.Events.enable(); - workspace.options.maxBlocks = Infinity; - this.props.onChangeCode(); - this.props.clearStats(); - if (!this.props.assessment) { - this.props.workspaceName(null); - } - this.setState({ snackbar: true, key: Date.now(), message: 'Das Projekt wurde erfolgreich zurückgesetzt.' }); - } - - - - render() { - return ( -
- {!this.props.assessment ? - -
{ this.setState({ file: true, open: true, saveFile: false, title: 'Projekt benennen', content: 'Bitte gib einen Namen für das Projekt ein und bestätige diesen mit einem Klick auf \'Eingabe\'.' }) }}> - {this.props.name && !isWidthDown('xs', this.props.width) ? {this.props.name} : null} -
- -
-
-
- : null} - {this.props.assessment ? : } - - { this.createFileName('xml'); }} - > - - - - {!this.props.assessment? -
- { this.uploadXmlFile(e.target.files[0]) }} - id="open-blocks" - type="file" - /> - -
- : null} - {!this.props.assessment? - - { this.createFileName('svg'); }} - > - - - - : null} - - this.resetWorkspace()} - > - - - - {!this.props.assessment? - - this.shareBlocks()} - > - - - - :null} - - - Dein Link wurde erstellt. - - - Über den folgenden Link kannst du dein Programm teilen. - - - - - - - - - { this.toggleDialog(); this.setState({ name: this.props.name }) } : this.toggleDialog} - button={this.state.file ? 'Abbrechen' : 'Schließen'} - > - {this.state.file ? -
- - -
- : null} -
- - - -
- ); - }; -} - -WorkspaceFunc.propTypes = { - arduino: PropTypes.string.isRequired, - xml: PropTypes.string.isRequired, - name: PropTypes.string, - clearStats: PropTypes.func.isRequired, - onChangeCode: PropTypes.func.isRequired, - workspaceName: PropTypes.func.isRequired -}; - -const mapStateToProps = state => ({ - arduino: state.workspace.code.arduino, - xml: state.workspace.code.xml, - name: state.workspace.name -}); - -export default connect(mapStateToProps, { clearStats, onChangeCode, workspaceName })(withStyles(styles, { withTheme: true })(withWidth()(WorkspaceFunc))); diff --git a/src/reducers/authReducer.js b/src/reducers/authReducer.js new file mode 100644 index 0000000..482af0c --- /dev/null +++ b/src/reducers/authReducer.js @@ -0,0 +1,61 @@ +import { MYBADGES_CONNECT, MYBADGES_DISCONNECT, USER_LOADED, USER_LOADING, AUTH_ERROR, LOGIN_SUCCESS, LOGIN_FAIL, LOGOUT_SUCCESS, LOGOUT_FAIL, REFRESH_TOKEN_SUCCESS } from '../actions/types'; + + +const initialState = { + token: localStorage.getItem('token'), + refreshToken: localStorage.getItem('refreshToken'), + isAuthenticated: null, + progress: false, + user: null +}; + +export default function(state = initialState, action){ + switch(action.type){ + case USER_LOADING: + return { + ...state, + progress: true + }; + case USER_LOADED: + return { + ...state, + isAuthenticated: true, + progress: false, + user: action.payload + }; + case LOGIN_SUCCESS: + case REFRESH_TOKEN_SUCCESS: + localStorage.setItem('token', action.payload.token); + localStorage.setItem('refreshToken', action.payload.refreshToken); + return { + ...state, + user: action.payload.user, + token: action.payload.token, + refreshToken: action.payload.refreshToken, + isAuthenticated: true, + progress: false + }; + case MYBADGES_CONNECT: + case MYBADGES_DISCONNECT: + return { + ...state, + user: action.payload + }; + case AUTH_ERROR: + case LOGIN_FAIL: + case LOGOUT_SUCCESS: + case LOGOUT_FAIL: + localStorage.removeItem('token'); + localStorage.removeItem('refreshToken'); + return { + ...state, + token: null, + refreshToken: null, + user: null, + isAuthenticated: false, + progress: false + }; + default: + return state; + } +} diff --git a/src/reducers/index.js b/src/reducers/index.js index 16eae53..cdedf04 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -3,12 +3,16 @@ import workspaceReducer from './workspaceReducer'; import tutorialReducer from './tutorialReducer'; import tutorialBuilderReducer from './tutorialBuilderReducer'; import generalReducer from './generalReducer'; +import projectReducer from './projectReducer'; import messageReducer from './messageReducer'; +import authReducer from './authReducer'; export default combineReducers({ + auth: authReducer, workspace: workspaceReducer, tutorial: tutorialReducer, builder: tutorialBuilderReducer, + project: projectReducer, general: generalReducer, message: messageReducer }); diff --git a/src/reducers/projectReducer.js b/src/reducers/projectReducer.js new file mode 100644 index 0000000..c97d298 --- /dev/null +++ b/src/reducers/projectReducer.js @@ -0,0 +1,40 @@ +import { PROJECT_PROGRESS, GET_PROJECT, GET_PROJECTS, PROJECT_TYPE, PROJECT_DESCRIPTION } from '../actions/types'; + +const initialState = { + projects: [], + type: '', + description: '', + progress: false +}; + +export default function (state = initialState, action) { + switch (action.type) { + case PROJECT_PROGRESS: + return { + ...state, + progress: !state.progress + } + case GET_PROJECTS: + return { + ...state, + projects: action.payload + }; + case GET_PROJECT: + return { + ...state, + projects: [action.payload] + } + case PROJECT_TYPE: + return { + ...state, + type: action.payload + } + case PROJECT_DESCRIPTION: + return { + ...state, + description: action.payload + } + default: + return state; + } +} diff --git a/src/reducers/tutorialBuilderReducer.js b/src/reducers/tutorialBuilderReducer.js index 132de0a..cad4c2e 100644 --- a/src/reducers/tutorialBuilderReducer.js +++ b/src/reducers/tutorialBuilderReducer.js @@ -1,10 +1,11 @@ -import { PROGRESS, JSON_STRING, BUILDER_CHANGE, BUILDER_ERROR, BUILDER_TITLE, BUILDER_ID, BUILDER_ADD_STEP, BUILDER_DELETE_STEP, BUILDER_CHANGE_STEP,BUILDER_CHANGE_ORDER, BUILDER_DELETE_PROPERTY } from '../actions/types'; +import { PROGRESS, JSON_STRING, BUILDER_CHANGE, BUILDER_ERROR, BUILDER_TITLE, BUILDER_BADGE, BUILDER_ID, BUILDER_ADD_STEP, BUILDER_DELETE_STEP, BUILDER_CHANGE_STEP,BUILDER_CHANGE_ORDER, BUILDER_DELETE_PROPERTY } from '../actions/types'; const initialState = { change: 0, progress: false, json: '', title: '', + badge: '', id: '', steps: [ { @@ -33,6 +34,11 @@ export default function(state = initialState, action){ ...state, title: action.payload }; + case BUILDER_BADGE: + return { + ...state, + badge: action.payload + }; case BUILDER_ID: return { ...state,