diff options
Diffstat (limited to 'src')
44 files changed, 1195 insertions, 1332 deletions
diff --git a/src/client/javascript/analytics.js b/src/client/javascript/analytics.js deleted file mode 100644 index a93c8a4..0000000 --- a/src/client/javascript/analytics.js +++ /dev/null @@ -1,35 +0,0 @@ -/* global ga */ - -const self = {} - -self.send = {} - -self.send.search = function (selectedUser, favorite) { - const hitType = 'event' - - const eventCategory = favorite ? 'search fav' : 'search' - - let eventAction - switch (selectedUser.type) { - case 'c': - eventAction = 'Class' - break - case 't': - eventAction = 'Teacher' - break - case 'r': - eventAction = 'Room' - break - case 's': - eventAction = 'Student' - break - } - - const eventLabel = selectedUser.value - - ga(function () { - ga('send', { hitType, eventCategory, eventAction, eventLabel }) - }) -} - -module.exports = self diff --git a/src/client/javascript/autocomplete.js b/src/client/javascript/autocomplete.js deleted file mode 100644 index 61f400a..0000000 --- a/src/client/javascript/autocomplete.js +++ /dev/null @@ -1,87 +0,0 @@ -const EventEmitter = require('events') - -const self = new EventEmitter() - -self._users = [] -self._selectedUserIndex = -1 - -self._nodes = { - search: document.querySelector('#search'), - input: document.querySelector('input[type="search"]'), - autocomplete: document.querySelector('.autocomplete') -} - -self.getSelectedUser = function () { - if (self.getItems() === []) return - - if (self.getSelectedUserIndex() === -1) { - return self.getItems()[0] - } else { - return self.getItems()[self.getSelectedUserIndex()] - } -} - -self.getSelectedUserIndex = function () { - return self._selectedUserIndex -} - -self.getItems = function () { - return self._users -} - -self.removeAllItems = function () { - while (self._nodes.autocomplete.firstChild) { - self._nodes.autocomplete.removeChild(self._nodes.autocomplete.firstChild) - } - self._users = [] - self._selectedUserIndex = -1 -} - -self.addItem = function (user) { - const listItem = document.createElement('li') - listItem.textContent = user.value - self._nodes.autocomplete.appendChild(listItem) - self._users.push(user) -} - -self._moveSelected = function (shift) { - if (self._selectedUserIndex + shift >= self.getItems().length) { - self._selectedUserIndex = -1 - } else if (self._selectedUserIndex + shift < -1) { - self._selectedUserIndex = self.getItems().length - 1 - } else { - self._selectedUserIndex += shift - } - - for (let i = 0; i < self.getItems().length; i++) { - self._nodes.autocomplete.children[i].classList.remove('selected') - } - if (self._selectedUserIndex >= 0) { - self._nodes.autocomplete - .children[self._selectedUserIndex].classList.add('selected') - } -} - -self._handleItemClick = function (event) { - if (!self._nodes.autocomplete.contains(event.target)) return - const userIndex = Array.prototype.indexOf - .call(self._nodes.autocomplete.children, event.target) - self._selectedUserIndex = userIndex - self.emit('select', self.getSelectedUser()) -} - -self._handleKeydown = function (event) { - if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { - event.preventDefault() - if (event.key === 'ArrowDown') { - self._moveSelected(1) - } else if (event.key === 'ArrowUp') { - self._moveSelected(-1) - } - } -} - -self._nodes.autocomplete.addEventListener('click', self._handleItemClick) -self._nodes.input.addEventListener('keydown', self._handleKeydown) - -module.exports = self diff --git a/src/client/javascript/browserFixToolkit.js b/src/client/javascript/browserFixToolkit.js deleted file mode 100644 index fbeab74..0000000 --- a/src/client/javascript/browserFixToolkit.js +++ /dev/null @@ -1,12 +0,0 @@ -const self = {} - -self.isIE = navigator.userAgent.indexOf('MSIE') !== -1 || - navigator.appVersion.indexOf('Trident/') > 0 - -if (self.isIE) { - self.inputEvent = 'textinput' -} else { - self.inputEvent = 'input' -} - -module.exports = self diff --git a/src/client/javascript/favorite.js b/src/client/javascript/favorite.js deleted file mode 100644 index 92c87f7..0000000 --- a/src/client/javascript/favorite.js +++ /dev/null @@ -1,79 +0,0 @@ -/* global USERS */ - -const EventEmitter = require('events') - -const self = new EventEmitter() - -self._nodes = { - toggle: document.querySelector('.fav') -} - -self.get = function () { - try { - const localStorageUser = JSON.parse(window.localStorage.getItem('fav')) - if (localStorageUser == null) return - - const correctedUser = USERS.filter(function (user) { - return user.type === localStorageUser.type && - user.value === localStorageUser.value - })[0] - return correctedUser - } catch (e) { - self.delete() - return - } -} - -self.set = function (user) { - window.localStorage.setItem('fav', JSON.stringify(user)) - self._nodes.innerHTML = '' -} - -self.delete = function () { - window.localStorage.removeItem('fav') -} - -self.updateDom = function (isFavorite) { - if (isFavorite) { - self._nodes.toggle.innerHTML = '' - } else { - self._nodes.toggle.innerHTML = '' - } -} - -self.update = function (selectedUser) { - const currentUser = self.get() - - if (currentUser == null || selectedUser == null) { - self.updateDom(false) - return - } - - const isEqual = currentUser.type === selectedUser.type && - currentUser.index === selectedUser.index - - self.updateDom(isEqual) -} - -self.toggle = function (selectedUser) { - const currentUser = self.get() - const isEqual = currentUser != null && - currentUser.type === selectedUser.type && - currentUser.index === selectedUser.index - - if (isEqual) { - self.delete() - self.updateDom(false) - } else { - self.set(selectedUser) - self.updateDom(true) - } -} - -self._handleClick = function () { - self.emit('click') -} - -self._nodes.toggle.addEventListener('click', self._handleClick) - -module.exports = self diff --git a/src/client/javascript/featureDetect.js b/src/client/javascript/featureDetect.js deleted file mode 100644 index 3a072a1..0000000 --- a/src/client/javascript/featureDetect.js +++ /dev/null @@ -1,29 +0,0 @@ -/* global FLAGS */ - -const self = {} - -self._nodes = { - input: document.querySelector('input[type="search"]'), - overflowButton: document.querySelector('#overflow-button') -} - -self._shouldCheck = function () { - return FLAGS.indexOf('NO_FEATURE_DETECT') === -1 -} - -self._redirect = function () { - window.location.href = 'http://www.meetingpointmco.nl/Roosters-AL/doc/' -} - -self.check = function () { - if (!self._shouldCheck()) return - - window.onerror = self._redirect - - if (self._nodes.input.getClientRects()[0].top !== - self._nodes.overflowButton.getClientRects()[0].top) { - self._redirect() - } -} - -module.exports = self diff --git a/src/client/javascript/frontpage.js b/src/client/javascript/frontpage.js deleted file mode 100644 index 17cb539..0000000 --- a/src/client/javascript/frontpage.js +++ /dev/null @@ -1,23 +0,0 @@ -const browserFixToolkit = require('./browserFixToolkit') - -const self = {} - -self._nodes = { - input: document.querySelector('input[type="search"]') -} - -self.isShown = false - -self.show = function () { - document.body.classList.add('no-input') - self.isShown = true -} - -self.hide = function () { - document.body.classList.remove('no-input') - self.isShown = false -} - -self._nodes.input.addEventListener(browserFixToolkit.inputEvent, self.hide) - -module.exports = self diff --git a/src/client/javascript/main.js b/src/client/javascript/main.js deleted file mode 100644 index 0d125cb..0000000 --- a/src/client/javascript/main.js +++ /dev/null @@ -1,71 +0,0 @@ -require('./featureDetect').check() -require('./zoom') - -const frontpage = require('./frontpage') -const search = require('./search') -const schedule = require('./schedule') -const weekSelector = require('./weekSelector') -const favorite = require('./favorite') -const scrollSnap = require('./scrollSnap') -const analytics = require('./analytics') -const url = require('./url') - -const state = {} - -window.state = state - -frontpage.show() -weekSelector.updateCurrentWeek() -scrollSnap.startListening() - -if (url.hasSelectedUser()) { - state.selectedUser = url.getSelectedUser() - - favorite.update(state.selectedUser) - url.update(state.selectedUser) - analytics.send.search(state.selectedUser) - - schedule.viewItem(weekSelector.getSelectedWeek(), state.selectedUser) -} else if (favorite.get() != null) { - state.selectedUser = favorite.get() - - favorite.update(state.selectedUser) - url.push(state.selectedUser, false) - url.update(state.selectedUser) - analytics.send.search(state.selectedUser, true) - - schedule.viewItem(weekSelector.getSelectedWeek(), state.selectedUser) -} else { - search.focus() -} - -search.on('search', function (selectedUser) { - state.selectedUser = selectedUser - - favorite.update(state.selectedUser) - url.push(state.selectedUser) - url.update(state.selectedUser) - analytics.send.search(state.selectedUser) - - schedule.viewItem(weekSelector.getSelectedWeek(), state.selectedUser) -}) - -url.on('update', function (selectedUser) { - state.selectedUser = selectedUser - - favorite.update(state.selectedUser) - url.update(state.selectedUser) - - schedule.viewItem(weekSelector.getSelectedWeek(), state.selectedUser) -}) - -weekSelector.on('weekChanged', function (newWeek) { - analytics.send.search(state.selectedUser) - schedule.viewItem(newWeek, state.selectedUser) -}) - -favorite.on('click', function () { - favorite.toggle(state.selectedUser) -}) - -document.body.style.opacity = 1 diff --git a/src/client/javascript/schedule.js b/src/client/javascript/schedule.js deleted file mode 100644 index 11a2aa8..0000000 --- a/src/client/javascript/schedule.js +++ /dev/null @@ -1,75 +0,0 @@ -/* global VALID_WEEK_NUMBERS */ - -const EventEmitter = require('events') -const search = require('./search') - -const self = new EventEmitter() - -self._nodes = { - schedule: document.querySelector('#schedule') -} - -self._parseMeetingpointHTML = function (htmlStr) { - const html = document.createElement('html') - html.innerHTML = htmlStr - const centerNode = html.querySelector('center') - return centerNode -} - -self._handleLoad = function (event) { - const request = event.target - if (request.status < 200 || request.status >= 400) { - self._handleError(event) - return - } - const document = self._parseMeetingpointHTML(request.response) - self._removeChilds() - self._nodes.schedule.appendChild(document) - self._nodes.schedule.classList.remove('error') - self.emit('load') -} - -self._handleError = function (event) { - const request = event.target - let error - if (request.status === 404) { - error = 'Sorry, er is (nog) geen rooster voor deze week.' - } else { - error = 'Sorry, er is iets mis gegaan tijdens het laden van deze week.' - } - self._removeChilds() - self._nodes.schedule.textContent = error - self._nodes.schedule.classList.add('error') - self.emit('load') -} - -self._getURLOfUser = function (week, user) { - return `/get/${user.type}/${user.value}?week=${week}` -} - -self._removeChilds = function () { - while (self._nodes.schedule.firstChild) { - self._nodes.schedule.removeChild(self._nodes.schedule.firstChild) - } -} - -self.viewItem = function (week, selectedUser) { - if (selectedUser == null) { - self._removeChilds() - } else if (VALID_WEEK_NUMBERS.indexOf(week) === -1) { - self._handleError({ target: { status: 404 } }); - } else { - const url = self._getURLOfUser(week, selectedUser) - - self._removeChilds() - const request = new window.XMLHttpRequest() - request.addEventListener('load', self._handleLoad) - request.addEventListener('error', self._handleError) - request.open('GET', url, true) - request.send() - } - - search.updateDom(selectedUser) -} - -module.exports = self diff --git a/src/client/javascript/scrollSnap.js b/src/client/javascript/scrollSnap.js deleted file mode 100644 index afee979..0000000 --- a/src/client/javascript/scrollSnap.js +++ /dev/null @@ -1,59 +0,0 @@ -require('smoothscroll-polyfill').polyfill() - -const self = {} -const schedule = require('./schedule') - -self._nodes = { - search: document.querySelector('#search'), - weekSelector: document.querySelector('#week-selector') -} - -self._timeoutID = null - -self._getScrollPosition = function () { - return (document.documentElement && document.documentElement.scrollTop) || - document.body.scrollTop -} - -self._handleDoneScrolling = function () { - const scrollPosition = self._getScrollPosition() - const weekSelectorHeight = - self._nodes.weekSelector.clientHeight - self._nodes.search.clientHeight - if (scrollPosition < weekSelectorHeight && scrollPosition > 0) { - window.scroll({ top: weekSelectorHeight, left: 0, behavior: 'smooth' }) - } -} - -self._handleScroll = function () { - if (self._timeoutID != null) window.clearTimeout(self._timeoutID) - self._timeoutID = window.setTimeout(self._handleDoneScrolling, 500) - - const scrollPosition = self._getScrollPosition() - const weekSelectorHeight = - self._nodes.weekSelector.clientHeight - self._nodes.search.clientHeight - if (scrollPosition >= weekSelectorHeight) { - document.body.classList.add('week-selector-not-visible') - } else { - document.body.classList.remove('week-selector-not-visible') - } -} - -self._handleWindowResize = function () { - const weekSelectorHeight = - self._nodes.weekSelector.clientHeight - self._nodes.search.clientHeight - const extraPixelsNeeded = - weekSelectorHeight - (document.body.clientHeight - window.innerHeight) - if (extraPixelsNeeded > 0) { - document.body.style.marginBottom = extraPixelsNeeded + 'px' - } else { - document.body.style.marginBottom = null - } -} - -self.startListening = function () { - window.addEventListener('scroll', self._handleScroll) -} - -schedule.on('load', self._handleWindowResize) -window.addEventListener('resize', self._handleWindowResize) -module.exports = self diff --git a/src/client/javascript/search.js b/src/client/javascript/search.js deleted file mode 100644 index 96413b0..0000000 --- a/src/client/javascript/search.js +++ /dev/null @@ -1,95 +0,0 @@ -/* global USERS */ - -const EventEmitter = require('events') -const fuzzy = require('fuzzy') -const autocomplete = require('./autocomplete') -const browserFixToolkit = require('./browserFixToolkit') - -const self = new EventEmitter() - -self._nodes = { - search: document.querySelector('#search'), - input: document.querySelector('input[type="search"]') -} - -self.submit = function () { - const selectedUser = autocomplete.getSelectedUser() - if (selectedUser == null) return - - console.log(selectedUser) - - self._nodes.input.blur() - document.body.classList.remove('week-selector-not-visible') // Safari bug - - self.emit('search', selectedUser) -} - -self.updateDom = function (selectedUser) { - if (selectedUser == null) { - self._nodes.input.value = '' - autocomplete.removeAllItems() - document.body.classList.add('no-input') - document.body.classList.remove('searched') - } else { - self._nodes.input.value = selectedUser.value - autocomplete.removeAllItems() - document.body.classList.remove('no-input') - document.body.classList.add('searched') - } -} - -self.focus = function () { - self._nodes.input.focus() -} - -self._handleSubmit = function (event) { - event.preventDefault() - self.submit() -} - -self._calculate = function (searchTerm) { - const allResults = fuzzy.filter(searchTerm, USERS, { - extract: function (user) { return user.value } - }) - const firstResults = allResults.slice(0, 7) - - const originalResults = firstResults.map(function (result) { - return result.original - }) - - return originalResults -} - -self._handleTextUpdate = function () { - const results = self._calculate(self._nodes.input.value) - - autocomplete.removeAllItems() - for (let i = 0; i < results.length; i++) { - autocomplete.addItem(results[i]) - } -} - -self._handleFocus = function () { - self._nodes.input.select() -} - -self._handleBlur = function () { - // this will removed the selection without drawing focus on it (safari) - // this will removed selection even when focusing an iframe (chrome) - const oldValue = self._nodes.value - self._nodes.value = '' - self._nodes.value = oldValue - - // this will hide the keyboard (iOS safari) - document.activeElement.blur() -} - -autocomplete.on('select', self.submit) - -self._nodes.search.addEventListener('submit', self._handleSubmit) -self._nodes.input.addEventListener('focus', self._handleFocus) -self._nodes.input.addEventListener('blur', self._handleBlur) -self._nodes.input.addEventListener(browserFixToolkit.inputEvent, - self._handleTextUpdate) - -module.exports = self diff --git a/src/client/javascript/url.js b/src/client/javascript/url.js deleted file mode 100644 index 17ab7c8..0000000 --- a/src/client/javascript/url.js +++ /dev/null @@ -1,67 +0,0 @@ -/* global USERS FLAGS */ - -const EventEmitter = require('events') - -const self = new EventEmitter() - -self._getPageTitle = function (selectedUser) { - let ret - - if (selectedUser == null) { - ret = `Metis Rooster` - } else { - ret = `Metis Rooster - ${selectedUser.value}` - } - - if (FLAGS.indexOf('BETA') !== -1) { - ret = `BETA ${ret}` - } - - return ret -} - -self._getPageURL = function (selectedUser) { - return `/${selectedUser.type}/${selectedUser.value}` -} - -self.push = function (selectedUser, push) { - if (push == null) push = true - const pageTitle = self._getPageTitle(selectedUser) - const pageURL = self._getPageURL(selectedUser) - if (push) { - window.history.pushState(selectedUser, pageTitle, pageURL) - } else { - window.history.replaceState(selectedUser, pageTitle, pageURL) - } -} - -self.update = function (selectedUser) { - document.title = self._getPageTitle(selectedUser) -} - -self.hasSelectedUser = function () { - const pageUrl = window.location.pathname - return /^\/s\/|^\/t\/|^\/r\/|^\/c\//.test(pageUrl) -} - -self.getSelectedUser = function () { - const pageUrl = window.location.pathname - const pageUrlData = pageUrl.split('/') - const type = pageUrlData[1] - const value = pageUrlData[2] - - const user = USERS.filter(function (user) { - return user.type === type && - user.value === value - })[0] - - return user -} - -self._handleUpdate = function (event) { - self.emit('update', event.state) -} - -window.addEventListener('popstate', self._handleUpdate) - -module.exports = self diff --git a/src/client/javascript/weekSelector.js b/src/client/javascript/weekSelector.js deleted file mode 100644 index d4e7f2a..0000000 --- a/src/client/javascript/weekSelector.js +++ /dev/null @@ -1,99 +0,0 @@ -const EventEmitter = require('events') - -const self = new EventEmitter() - -self._nodes = { - prevButton: document.querySelectorAll('#week-selector button')[0], - nextButton: document.querySelectorAll('#week-selector button')[1], - currentWeekNode: document.querySelector('#week-selector .current'), - currentWeekNormalText: document.querySelector('#week-selector .current .no-print'), - currentWeekPrintText: document.querySelector('#week-selector .current .print') -} - -self._weekOffset = 0 - -// copied from http://www.meetingpointmco.nl/Roosters-AL/doc/dagroosters/untisscripts.js, -// were using the same code as they do to be sure that we always get the same -// week number. -self.getCurrentWeek = function (target) { - const dayNr = (target.getDay() + 6) % 7 - target.setDate(target.getDate() - dayNr + 3) - const firstThursday = target.valueOf() - target.setMonth(0, 1) - if (target.getDay() !== 4) { - target.setMonth(0, 1 + ((4 - target.getDay()) + 7) % 7) - } - - return 1 + Math.ceil((firstThursday - target) / 604800000) -} - -self.getSelectedWeek = function () { - const now = new Date() - const targetDate = new Date(now.getTime() + - self._weekOffset * 604800 * 1000 + 86400 * 1000) - return self.getCurrentWeek(targetDate) -} - -self.updateCurrentWeek = function () { - const selectedWeekNumber = self.getSelectedWeek() - if (self.getCurrentWeek(new Date()) !== selectedWeekNumber) { - self._nodes.currentWeekNode.classList.add('changed') - } else { - self._nodes.currentWeekNode.classList.remove('changed') - } - self.updateDom() - self.emit('weekChanged', selectedWeekNumber) -} - -self.updateDom = function () { - const selectedWeekNumber = self.getSelectedWeek() - const isSunday = new Date().getDay() === 0 - let humanReadableWeek = null - if (isSunday) { - switch (self._weekOffset) { - case 0: - humanReadableWeek = 'Aanstaande week' - break - case 1: - humanReadableWeek = 'Volgende week' - break - case -1: - humanReadableWeek = 'Afgelopen week' - break - } - } else { - switch (self._weekOffset) { - case 0: - humanReadableWeek = 'Huidige week' - break - case 1: - humanReadableWeek = 'Volgende week' - break - case -1: - humanReadableWeek = 'Vorige week' - break - } - } - if (humanReadableWeek != null) { - self._nodes.currentWeekNormalText.textContent = humanReadableWeek + ' • ' + selectedWeekNumber - self._nodes.currentWeekPrintText.textContent = 'Week ' + selectedWeekNumber - } else { - self._nodes.currentWeekNormalText.textContent = 'Week ' + selectedWeekNumber - self._nodes.currentWeekPrintText.textContent = 'Week ' + selectedWeekNumber - } -} - -self._handlePrevButtonClick = function () { - self._weekOffset -= 1 - self.updateCurrentWeek() -} - -self._handleNextButtonClick = function () { - self._weekOffset += 1 - self.updateCurrentWeek() -} - -self._nodes.prevButton.addEventListener('click', self._handlePrevButtonClick) -self._nodes.nextButton.addEventListener('click', self._handleNextButtonClick) - -module.exports = self diff --git a/src/client/javascript/zoom.js b/src/client/javascript/zoom.js deleted file mode 100644 index 59b80db..0000000 --- a/src/client/javascript/zoom.js +++ /dev/null @@ -1,30 +0,0 @@ -const schedule = require('./schedule') - -const self = {} - -self._nodes = { - body: document.body -} - -self._handleResize = function () { - // the table node may not exist before this function is called - const tableNode = document.querySelector('center > table') - - // infact, it may not even exist when this function is called. - if (!tableNode) return - - const tableWidth = tableNode.getBoundingClientRect().width - const tableGoalWidth = self._nodes.body.getBoundingClientRect().width * 0.9 - const zoomFactor = tableGoalWidth / tableWidth - - if (zoomFactor < 1) { - tableNode.style.zoom = `${zoomFactor}` - } else { - tableNode.style.zoom = `1` - } -} - -schedule.on('load', self._handleResize) -window.addEventListener('resize', self._handleResize) - -module.exports = self diff --git a/src/client/react/actions/search.js b/src/client/react/actions/search.js new file mode 100644 index 0000000..22daeca --- /dev/null +++ b/src/client/react/actions/search.js @@ -0,0 +1,19 @@ +export const setUser = user => ({ + type: 'SEARCH/SET_USER', + user, +}); + +export const inputChange = searchText => ({ + type: 'SEARCH/INPUT_CHANGE', + searchText, +}); + +/** + * Change the selected result. + * @param {+1/-1} relativeChange usually +1 or -1, the change relative to the + * current result. + */ +export const changeSelectedResult = relativeChange => ({ + type: 'SEARCH/CHANGE_SELECTED_RESULT', + relativeChange, +}); diff --git a/src/client/react/actions/view.js b/src/client/react/actions/view.js new file mode 100644 index 0000000..79ec143 --- /dev/null +++ b/src/client/react/actions/view.js @@ -0,0 +1,31 @@ +// eslint-disable-next-line import/prefer-default-export +export const fetchSchedule = (user, week) => (dispatch) => { + dispatch({ + type: 'VIEW/FETCH_SCHEDULE_REQUEST', + user, + week, + }); + + fetch(`/get/${user}?week=${week}`).then( + // success + (r) => { + r.text().then((htmlStr) => { + dispatch({ + type: 'VIEW/FETCH_SCHEDULE_SUCCESS', + user, + week, + htmlStr, + }); + }); + }, + + // error + () => { + dispatch({ + type: 'VIEW/FETCH_SCHEDULE_FAILURE', + week, + user, + }); + }, + ); +}; diff --git a/src/client/react/components/container/HelpBox.js b/src/client/react/components/container/HelpBox.js new file mode 100644 index 0000000..a74b43c --- /dev/null +++ b/src/client/react/components/container/HelpBox.js @@ -0,0 +1,30 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; + +const HelpBox = ({ results, searchText }) => { + if (results.length > 0 || searchText !== '') { + return <div />; + } + + return ( + <div className="help-box"> + <div className="arrow" /> + <div className="bubble"> + Voer hier een docentafkorting, klas, leerlingnummer of lokaalnummer in. + </div> + </div> + ); +}; + +HelpBox.propTypes = { + results: PropTypes.arrayOf(PropTypes.string).isRequired, + searchText: PropTypes.string.isRequired, +}; + +const mapStateToProps = state => ({ + results: state.search.results, + searchText: state.search.searchText, +}); + +export default connect(mapStateToProps)(HelpBox); diff --git a/src/client/react/components/container/Results.js b/src/client/react/components/container/Results.js new file mode 100644 index 0000000..1fb5f44 --- /dev/null +++ b/src/client/react/components/container/Results.js @@ -0,0 +1,38 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import classnames from 'classnames'; +import Result from '../presentational/Result'; + +const Results = (({ results, isExactMatch, selectedResult }) => ( + <div + className={classnames('search__results', { + 'search__results--has-results': !isExactMatch && results.length > 0, + })} + style={{ + minHeight: isExactMatch ? 0 : results.length * 54, + }} + > + {!isExactMatch && results.map(userId => ( + <Result key={userId} userId={userId} isSelected={userId === selectedResult} /> + ))} + </div> +)); + +Results.propTypes = { + results: PropTypes.arrayOf(PropTypes.string).isRequired, + isExactMatch: PropTypes.bool.isRequired, + selectedResult: PropTypes.string, +}; + +Results.defaultProps = { + selectedResult: null, +}; + +const mapStateToProps = state => ({ + results: state.search.results, + isExactMatch: state.search.isExactMatch, + selectedResult: state.search.selectedResult, +}); + +export default connect(mapStateToProps)(Results); diff --git a/src/client/react/components/container/Search.js b/src/client/react/components/container/Search.js new file mode 100644 index 0000000..8acbe99 --- /dev/null +++ b/src/client/react/components/container/Search.js @@ -0,0 +1,142 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import classnames from 'classnames'; +import { withRouter } from 'react-router-dom'; + +import SearchIcon from 'react-icons/lib/md/search'; + +import { setUser, inputChange, changeSelectedResult } from '../../actions/search'; + +import users from '../../users'; +import Results from './Results'; +import IconFromUserType from '../presentational/IconFromUserType'; + +class Search extends React.Component { + constructor(props) { + super(props); + + this.state = { + hasFocus: false, + }; + + this.onFocus = this.onFocus.bind(this); + this.onBlur = this.onBlur.bind(this); + this.onKeyDown = this.onKeyDown.bind(this); + } + + componentDidMount() { + this.props.dispatch(setUser(this.props.urlUser)); + } + + componentWillReceiveProps(nextProps) { + if (nextProps.urlUser !== this.props.urlUser) { + this.props.dispatch(setUser(nextProps.urlUser)); + } + } + + onFocus() { + this.setState({ + hasFocus: true, + }); + } + + onBlur() { + this.setState({ + hasFocus: false, + }); + } + + onKeyDown(event) { + if (event.key === 'ArrowUp' || event.key === 'ArrowDown' || event.key === 'Enter') { + event.preventDefault(); + switch (event.key) { + case 'ArrowUp': + this.props.dispatch(changeSelectedResult(-1)); + break; + case 'ArrowDown': + this.props.dispatch(changeSelectedResult(+1)); + break; + case 'Enter': { + const result = this.props.selectedResult || this.props.results[0]; + + if (result === this.props.urlUser) { + // EDGE CASE: The user is set if the user changes, but it doesn't + // change if the result is already the one we are viewing. + // Therefor, we need to dispatch the SET_USER command manually. + this.props.dispatch(setUser(this.props.urlUser)); + } else if (result) { + this.props.history.push(`/${result}`); + } + } + break; + default: + throw new Error('This should never happen... pls?'); + } + } + } + + render() { + const { + selectedResult, + isExactMatch, + searchText, + dispatch, + } = this.props; + + const { + hasFocus, + } = this.state; + + return ( + <div className="search"> + <div className={classnames('search-overflow', { 'search--has-focus': hasFocus })}> + <div className="search__input-wrapper"> + <div className="search__icon-wrapper"> + <IconFromUserType + userType={isExactMatch ? users.byId[selectedResult].type : null} + defaultIcon={<SearchIcon />} + /> + </div> + <input + id="search__input" + onChange={event => dispatch(inputChange(event.target.value))} + onKeyDown={this.onKeyDown} + value={searchText} + placeholder="Zoeken" + onFocus={this.onFocus} + onBlur={this.onBlur} + /> + </div> + <Results /> + </div> + </div> + ); + } +} + +Search.propTypes = { + results: PropTypes.arrayOf(PropTypes.string).isRequired, + selectedResult: PropTypes.string, + urlUser: PropTypes.string, + isExactMatch: PropTypes.bool.isRequired, + searchText: PropTypes.string.isRequired, + dispatch: PropTypes.func.isRequired, + history: PropTypes.shape({ + push: PropTypes.func.isRequired, + }).isRequired, +}; + +Search.defaultProps = { + selectedResult: null, + urlUser: null, +}; + +const mapStateToProps = state => ({ + results: state.search.results, + searchText: state.search.searchText, + selectedResult: state.search.selectedResult, + isExactMatch: state.search.isExactMatch, +}); + +export default withRouter(connect(mapStateToProps)(Search)); diff --git a/src/client/react/components/container/View.js b/src/client/react/components/container/View.js new file mode 100644 index 0000000..4f16100 --- /dev/null +++ b/src/client/react/components/container/View.js @@ -0,0 +1,47 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { withRouter } from 'react-router-dom'; + +import { fetchSchedule } from '../../actions/view'; +import extractSchedule from '../../lib/extractSchedule'; + +import Schedule from '../presentational/Schedule'; +import Loading from '../presentational/Loading'; + +const View = ({ + schedules, + user, + week, + dispatch, +}) => { + const schedule = extractSchedule(schedules, user, week); + + switch (schedule.state) { + case 'NOT_REQUESTED': + dispatch(fetchSchedule(user, week)); + return <Loading />; + case 'FETCHING': + return <Loading />; + case 'FINISHED': + return <Schedule htmlStr={schedule.htmlStr} />; + default: + throw new Error(`${schedule.state} is not a valid schedule state.`); + } +}; + +View.propTypes = { + schedules: PropTypes.objectOf(PropTypes.objectOf(PropTypes.shape({ + state: PropTypes.string.isRequired, + htmlStr: PropTypes.string, + }))).isRequired, + user: PropTypes.string.isRequired, + week: PropTypes.number.isRequired, + dispatch: PropTypes.func.isRequired, +}; + +const mapStateToProps = state => ({ + schedules: state.view.schedules, +}); + +export default withRouter(connect(mapStateToProps)(View)); diff --git a/src/client/react/components/container/WeekSelector.js b/src/client/react/components/container/WeekSelector.js new file mode 100644 index 0000000..eef8d8d --- /dev/null +++ b/src/client/react/components/container/WeekSelector.js @@ -0,0 +1,39 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import moment from 'moment'; +import queryString from 'query-string'; +import { withRouter } from 'react-router-dom'; + +import purifyWeek from '../../lib/purifyWeek'; + +const WeekSelector = ({ urlWeek, location, history }) => { + const updateWeek = (change) => { + const newWeek = purifyWeek(urlWeek + change); + const isCurrentWeek = moment().week() === newWeek; + + const query = queryString.stringify({ + week: isCurrentWeek ? undefined : newWeek, + }); + history.push(`${location.pathname}?${query}`); + }; + + return ( + <div> + <button onClick={() => updateWeek(-1)}>Prev</button> + Week {urlWeek} + <button onClick={() => updateWeek(+1)}>Next</button> + </div> + ); +}; + +WeekSelector.propTypes = { + urlWeek: PropTypes.number.isRequired, + history: PropTypes.shape({ + push: PropTypes.func.isRequired, + }).isRequired, + location: PropTypes.shape({ + pathname: PropTypes.string.isRequired, + }).isRequired, +}; + +export default withRouter(WeekSelector); diff --git a/src/client/react/components/page/Index.js b/src/client/react/components/page/Index.js new file mode 100644 index 0000000..e5e47c5 --- /dev/null +++ b/src/client/react/components/page/Index.js @@ -0,0 +1,15 @@ +import React from 'react'; +import Search from '../container/Search'; +import HelpBox from '../container/HelpBox'; + +const IndexPage = () => ( + <div className="page-index"> + <div className="container"> + <img src="/icons/mml-logo.png" alt="Metis" /> + <Search /> + <HelpBox /> + </div> + </div> +); + +export default IndexPage; diff --git a/src/client/react/components/page/User.js b/src/client/react/components/page/User.js new file mode 100644 index 0000000..215a6e0 --- /dev/null +++ b/src/client/react/components/page/User.js @@ -0,0 +1,47 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Redirect } from 'react-router-dom'; +import queryString from 'query-string'; +import moment from 'moment'; +import purifyWeek from '../../lib/purifyWeek'; +import Search from '../container/Search'; +import View from '../container/View'; +import users from '../../users'; +import WeekSelector from '../container/WeekSelector'; + +const UserPage = ({ match, location }) => { + const user = `${match.params.type}/${match.params.value}`; + const weekStr = queryString.parse(location.search).week; + const week = purifyWeek(weekStr ? parseInt(weekStr, 10) : moment().week()); + + if (!users.allIds.includes(user)) { + // Invalid user, redirect to index. + return <Redirect to="/" />; + } + + return ( + <div className="page-user"> + <div className="menu"> + <div className="menu-container"> + <Search urlUser={user} /> + <WeekSelector urlWeek={week} /> + </div> + </div> + <View user={user} week={week} /> + </div> + ); +}; + +UserPage.propTypes = { + match: PropTypes.shape({ + params: PropTypes.shape({ + type: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + }).isRequired, + }).isRequired, + location: PropTypes.shape({ + search: PropTypes.string.isRequired, + }).isRequired, +}; + +export default UserPage; diff --git a/src/client/react/components/presentational/IconFromUserType.js b/src/client/react/components/presentational/IconFromUserType.js new file mode 100644 index 0000000..ee0e04b --- /dev/null +++ b/src/client/react/components/presentational/IconFromUserType.js @@ -0,0 +1,37 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import StudentIcon from 'react-icons/lib/md/person'; +import RoomIcon from 'react-icons/lib/md/room'; +import ClassIcon from 'react-icons/lib/md/group'; +import TeacherIcon from 'react-icons/lib/md/account-circle'; + +const IconFromUserType = ({ userType, defaultIcon }) => { + switch (userType) { + case 'c': + return <ClassIcon />; + case 't': + return <TeacherIcon />; + case 's': + return <StudentIcon />; + case 'r': + return <RoomIcon />; + default: + if (defaultIcon) { + return defaultIcon; + } + + throw new Error('`userType` was invalid or not given, but `defaultIcon` is not defined.'); + } +}; + +IconFromUserType.propTypes = { + userType: PropTypes.string, + defaultIcon: PropTypes.element, +}; + +IconFromUserType.defaultProps = { + userType: null, + defaultIcon: null, +}; + +export default IconFromUserType; diff --git a/src/client/react/components/presentational/Loading.js b/src/client/react/components/presentational/Loading.js new file mode 100644 index 0000000..84eaac7 --- /dev/null +++ b/src/client/react/components/presentational/Loading.js @@ -0,0 +1,5 @@ +import React from 'react'; + +const Loading = () => <div>Loading...</div>; + +export default Loading; diff --git a/src/client/react/components/presentational/Result.js b/src/client/react/components/presentational/Result.js new file mode 100644 index 0000000..0b9e024 --- /dev/null +++ b/src/client/react/components/presentational/Result.js @@ -0,0 +1,24 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; +import users from '../../users'; + +import IconFromUserType from './IconFromUserType'; + +const Result = ({ userId, isSelected }) => ( + <div + className={classnames('search__result', { + 'search__result--selected': isSelected, + })} + > + <div className="search__icon-wrapper"><IconFromUserType userType={users.byId[userId].type} /></div> + <div className="search__result__text">{users.byId[userId].value}</div> + </div> +); + +Result.propTypes = { + userId: PropTypes.string.isRequired, + isSelected: PropTypes.bool.isRequired, +}; + +export default Result; diff --git a/src/client/react/components/presentational/Schedule.js b/src/client/react/components/presentational/Schedule.js new file mode 100644 index 0000000..256c1b4 --- /dev/null +++ b/src/client/react/components/presentational/Schedule.js @@ -0,0 +1,22 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import createDOMPurify from 'dompurify'; + +const Schedule = ({ htmlStr }) => { + const DOMPurify = createDOMPurify(window); + + const cleanHTML = DOMPurify.sanitize(htmlStr, { + ADD_ATTR: ['rules'], + }); + + return ( + // eslint-disable-next-line react/no-danger + <div dangerouslySetInnerHTML={{ __html: cleanHTML }} /> + ); +}; + +Schedule.propTypes = { + htmlStr: PropTypes.string.isRequired, +}; + +export default Schedule; diff --git a/src/client/react/index.js b/src/client/react/index.js new file mode 100644 index 0000000..122d54b --- /dev/null +++ b/src/client/react/index.js @@ -0,0 +1,36 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import moment from 'moment'; +import { Provider } from 'react-redux'; +import { BrowserRouter as Router, Route } from 'react-router-dom'; +import { createStore, applyMiddleware, compose } from 'redux'; +import logger from 'redux-logger'; +import thunk from 'redux-thunk'; +import reducer from './reducers'; +import Index from './components/page/Index'; +import User from './components/page/User'; + +moment.locale('nl'); + +// eslint-disable-next-line no-underscore-dangle +const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; +const store = createStore( + reducer, + composeEnhancers(applyMiddleware(thunk, logger)), +); + +ReactDOM.render( + <Provider store={store}> + <Router> + <div> + <Route exact path="/" component={Index} /> + <Route path="/:type/:value" component={User} /> + </div> + </Router> + </Provider>, + document.getElementById('root'), +); + +// We only want to focus the input on page load. NOT on a in-javascript +// redirect. This is because that is when people usually want to start typing. +document.querySelector('.search input').focus(); diff --git a/src/client/react/lib/extractSchedule.js b/src/client/react/lib/extractSchedule.js new file mode 100644 index 0000000..e74411b --- /dev/null +++ b/src/client/react/lib/extractSchedule.js @@ -0,0 +1,10 @@ +export default function extractSchedule(schedules, user, week) { + const scheduleExists = + schedules.hasOwnProperty(user) && schedules[user].hasOwnProperty(week); + + if (!scheduleExists) { + return { state: 'NOT_REQUESTED' }; + } + + return schedules[user][week]; +} diff --git a/src/client/react/lib/purifyWeek.js b/src/client/react/lib/purifyWeek.js new file mode 100644 index 0000000..939f5af --- /dev/null +++ b/src/client/react/lib/purifyWeek.js @@ -0,0 +1,9 @@ +import moment from 'moment'; + +export default function purifyWeek(week) { + // This ensures that week 0 will become week 52 and that week 53 will become + // week 1. This also accounts for leap years. Because date logic can be so + // complicated we off load it to moment.js so that we can be sure it's bug + // free. + return moment().week(week).week(); +} diff --git a/src/client/react/reducers.js b/src/client/react/reducers.js new file mode 100644 index 0000000..fb97228 --- /dev/null +++ b/src/client/react/reducers.js @@ -0,0 +1,10 @@ +import { combineReducers } from 'redux'; +import search from './reducers/search'; +import view from './reducers/view'; + +const rootReducer = combineReducers({ + search, + view, +}); + +export default rootReducer; diff --git a/src/client/react/reducers/search.js b/src/client/react/reducers/search.js new file mode 100644 index 0000000..770cdcb --- /dev/null +++ b/src/client/react/reducers/search.js @@ -0,0 +1,105 @@ +import fuzzy from 'fuzzy'; +import users from '../users'; + +const DEFAULT_STATE = { + // results: [ + // 's/18562', + // ], + results: [], + searchText: '', + selectedResult: null, + isExactMatch: false, +}; + +function getSearchResults(allUsers, query) { + if (query.trim() === '') { + return []; + } + + const allResults = fuzzy.filter(query, allUsers, { + extract: user => user.value, + }); + + const firstResults = allResults.splice(0, 4); + const userIds = firstResults.map(result => result.original.id); + + return userIds; +} + +const search = (state = DEFAULT_STATE, action) => { + switch (action.type) { + case 'SEARCH/SET_USER': { + const { user } = action; + + if (user == null) { + return DEFAULT_STATE; + } + + return { + ...state, + results: [], + searchText: users.byId[user].value, + selectedResult: user, + isExactMatch: true, + }; + } + + case 'SEARCH/INPUT_CHANGE': { + const { searchText } = action; + const results = getSearchResults(users.allUsers, action.searchText); + let selectedResult = null; + let isExactMatch = false; + + // Is the typed value exactly the same as the first result? Then show the + // appropriate icon instead of the generic search icon. + if ((results.length === 1) && (action.searchText === users.byId[results[0]].value)) { + [selectedResult] = results; + isExactMatch = true; + } + + return { + ...state, + results, + searchText, + selectedResult, + isExactMatch, + }; + } + + case 'SEARCH/CHANGE_SELECTED_RESULT': { + const { results, isExactMatch } = state; + + if (isExactMatch) return state; + + const prevSelectedResult = state.selectedResult; + const prevSelectedResultIndex = results.indexOf(prevSelectedResult); + let nextSelectedResultIndex = + prevSelectedResultIndex + action.relativeChange; + + if (nextSelectedResultIndex < -1) { + nextSelectedResultIndex = results.length - 1; + } else if (nextSelectedResultIndex > results.length - 1) { + nextSelectedResultIndex = -1; + } + + const nextSelectedResult = + nextSelectedResultIndex === -1 + ? null + : results[nextSelectedResultIndex]; + + return { + ...state, + selectedResult: nextSelectedResult, + }; + } + + default: + return state; + } +}; + +export default search; + +export const _test = { + DEFAULT_STATE, +}; diff --git a/src/client/react/reducers/search.test.js b/src/client/react/reducers/search.test.js new file mode 100644 index 0000000..22d32e2 --- /dev/null +++ b/src/client/react/reducers/search.test.js @@ -0,0 +1,229 @@ +window.USERS = [ + { type: 's', value: '18561' }, + { type: 's', value: '18562' }, + { type: 's', value: '18563' }, + { type: 's', value: '18564' }, + { type: 's', value: '18565' }, + { type: 's', value: '18566' }, + { type: 's', value: '18567' }, + { type: 's', value: '18568' }, + { type: 's', value: '18569' }, +]; + +const deepFreeze = require('deep-freeze'); +const search = require('./search').default; +const { _test } = require('./search'); +const { + setUser, + inputChange, + changeSelectedResult, +} = require('../actions/search'); + +describe('reducers', () => { + describe('search', () => { + describe('SEARCH/SET_USER', () => { + it('Resets to the default state if the user is null', () => { + expect(search({ foo: 'bar' }, setUser(null))).toEqual(_test.DEFAULT_STATE); + }); + + it('Sets all the values of that user properly', () => { + expect(search(undefined, setUser('s/18561'))).toEqual({ + results: [], + searchText: '18561', + selectedResult: 's/18561', + isExactMatch: true, + }); + }); + }); + + describe('SEARCH/INPUT_CHANGE', () => { + it('Returns no results when nothing is typed in', () => { + expect(search(undefined, inputChange(''))).toEqual({ + results: [], + searchText: '', + selectedResult: null, + isExactMatch: false, + }); + }); + + it('Returns no results when a space is typed in', () => { + expect(search(undefined, inputChange(' '))).toEqual({ + results: [], + searchText: ' ', + selectedResult: null, + isExactMatch: false, + }); + }); + + it('Preforms a basic search, only returning four results', () => { + expect(search(undefined, inputChange('18'))).toEqual({ + results: [ + 's/18561', + 's/18562', + 's/18563', + 's/18564', + ], + searchText: '18', + selectedResult: null, + isExactMatch: false, + }); + }); + + it('Selects the first result and sets isExactMatch to true when there is an exact match', () => { + expect(search(undefined, inputChange('18561'))).toEqual({ + results: [ + 's/18561', + ], + searchText: '18561', + selectedResult: 's/18561', + isExactMatch: true, + }); + }); + }); + + describe('SEARCH/CHANGE_SELECTED_RESULT', () => { + it('Does nothing when there are no results', () => { + const prevState = { + results: [], + searchText: '', + selectedResult: null, + isExactMatch: false, + }; + + const actionPlus = changeSelectedResult(+1); + const actionMin = changeSelectedResult(-1); + + deepFreeze([prevState, actionPlus, actionMin]); + + const nextStatePlus = search(prevState, actionPlus); + const nextStateMin = search(prevState, actionMin); + expect(nextStatePlus).toEqual(prevState); + expect(nextStateMin).toEqual(prevState); + }); + + it('Does nothing when there is an exact match', () => { + const prevState = { + results: ['s/18561'], + searchText: '18561', + selectedResult: 's/18561', + isExactMatch: true, + }; + + const actionPlus = changeSelectedResult(+1); + const actionMin = changeSelectedResult(-1); + + deepFreeze([prevState, actionPlus, actionMin]); + + const nextStatePlus = search(prevState, actionPlus); + const nextStateMin = search(prevState, actionMin); + + expect(nextStatePlus).toEqual(prevState); + expect(nextStateMin).toEqual(prevState); + }); + + it('Switches to the correct selectedResult when no selected result is selected', () => { + const prevState = { + results: ['s/18561', 's/18562', 's/18563'], + searchText: '1856', + selectedResult: null, + isExactMatch: false, + }; + + const actionPlus = changeSelectedResult(+1); + const actionMin = changeSelectedResult(-1); + + deepFreeze([prevState, actionPlus, actionMin]); + + const nextStatePlus = search(prevState, actionPlus); + const nextStateMin = search(prevState, actionMin); + + expect(nextStatePlus).toEqual({ + ...prevState, + selectedResult: 's/18561', + }); + expect(nextStateMin).toEqual({ + ...prevState, + selectedResult: 's/18563', + }); + }); + + it('Switches to the correct selectedResult when there is a selected result selected', () => { + const prevState = { + results: ['s/18561', 's/18562', 's/18563'], + searchText: '1856', + selectedResult: 's/18562', + isExactMatch: false, + }; + + const actionPlus = changeSelectedResult(+1); + const actionMin = changeSelectedResult(-1); + + deepFreeze([prevState, actionPlus, actionMin]); + + const nextStatePlus = search(prevState, actionPlus); + const nextStateMin = search(prevState, actionMin); + + expect(nextStatePlus).toEqual({ + ...prevState, + selectedResult: 's/18563', + }); + expect(nextStateMin).toEqual({ + ...prevState, + selectedResult: 's/18561', + }); + }); + + it('Properly wraps arround when incrementing', () => { + expect(search({ + results: ['s/18561', 's/18562', 's/18563'], + searchText: '1856', + selectedResult: 's/18563', + isExactMatch: false, + }, changeSelectedResult(+1))).toEqual({ + results: ['s/18561', 's/18562', 's/18563'], + searchText: '1856', + selectedResult: null, + isExactMatch: false, + }); + + expect(search({ + results: ['s/18561', 's/18562', 's/18563'], + searchText: '1856', + selectedResult: null, + isExactMatch: false, + }, changeSelectedResult(+1))).toEqual({ + results: ['s/18561', 's/18562', 's/18563'], + searchText: '1856', + selectedResult: 's/18561', + isExactMatch: false, + }); + }); + + it('Properly wraps arround when decrementing', () => { + expect(search({ + results: ['s/18561', 's/18562', 's/18563'], + searchText: '1856', + selectedResult: 's/18561', + isExactMatch: false, + }, changeSelectedResult(-1))).toEqual({ + results: ['s/18561', 's/18562', 's/18563'], + searchText: '1856', + selectedResult: null, + isExactMatch: false, + }); + + expect(search({ + results: ['s/18561', 's/18562', 's/18563'], + searchText: '1856', + selectedResult: null, + isExactMatch: false, + }, changeSelectedResult(-1))).toEqual({ + results: ['s/18561', 's/18562', 's/18563'], + searchText: '1856', + selectedResult: 's/18563', + isExactMatch: false, + }); + }); + }); + }); +}); diff --git a/src/client/react/reducers/view.js b/src/client/react/reducers/view.js new file mode 100644 index 0000000..301a1cf --- /dev/null +++ b/src/client/react/reducers/view.js @@ -0,0 +1,53 @@ +const schedule = (state = {}, action) => { + switch (action.type) { + case 'VIEW/FETCH_SCHEDULE_REQUEST': + return { + ...state, + state: 'FETCHING', + }; + case 'VIEW/FETCH_SCHEDULE_SUCCESS': + return { + ...state, + state: 'FINISHED', + htmlStr: action.htmlStr, + }; + default: + return state; + } +}; + +const DEFAULT_STATE = { + schedules: {}, +}; + +const view = (state = DEFAULT_STATE, action) => { + switch (action.type) { + case 'VIEW/FETCH_SCHEDULE_REQUEST': + case 'VIEW/FETCH_SCHEDULE_SUCCESS': + return { + ...state, + schedules: { + ...state.schedules, + [action.user]: + state.schedules[action.user] + ? { + // This user already exists in our state, extend it. + ...state.schedules[action.user], + [action.week]: schedule(state.schedules[action.user][action.week], action), + } + : { + // This user does not already exist in our state. + [action.week]: schedule(undefined, action), + }, + }, + }; + default: + return state; + } +}; + +export default view; + +export const _test = { + DEFAULT_STATE, +}; diff --git a/src/client/react/users.js b/src/client/react/users.js new file mode 100644 index 0000000..01ff093 --- /dev/null +++ b/src/client/react/users.js @@ -0,0 +1,66 @@ +/* global USERS */ + +import { combineReducers, createStore } from 'redux'; + +const getId = ({ type, value }) => `${type}/${value}`; + +const byId = (state = {}, action) => { + switch (action.type) { + case 'USERS/ADD_USER': + return { + ...state, + [action.user.id]: { + ...action.user, + }, + }; + default: + return state; + } +}; + +const allIds = (state = [], action) => { + switch (action.type) { + case 'USERS/ADD_USER': + return [ + ...state, + action.user.id, + ]; + default: + return state; + } +}; + +const allUsers = (state = [], action) => { + switch (action.type) { + case 'USERS/ADD_USER': + return [ + ...state, + { + ...action.user, + }, + ]; + default: + return state; + } +}; + +const store = createStore(combineReducers({ + byId, + allIds, + allUsers, +})); + +USERS.forEach((user) => { + store.dispatch({ + type: 'USERS/ADD_USER', + user: { + type: user.type, + value: user.value, + id: getId(user), + }, + }); +}); + +const users = store.getState(); + +export default users; diff --git a/src/client/static/stylesheets/hello.css b/src/client/static/stylesheets/hello.css deleted file mode 100644 index edcbc92..0000000 --- a/src/client/static/stylesheets/hello.css +++ /dev/null @@ -1,23 +0,0 @@ -* { - box-sizing: border-box; -} - -html, body { - margin: 0; - font-family: 'Roboto', sans-serif; - display: flex; - flex-direction: column; - justify-content: center; - text-align: center; - width: 100vw; - height: 100vh; - color: gray; -} - -.ideas { - font-size: 0.8em; -} - -a { - color: #3f51b5; -} diff --git a/src/client/static/stylesheets/print.css b/src/client/static/stylesheets/print.css deleted file mode 100644 index 0e09533..0000000 --- a/src/client/static/stylesheets/print.css +++ /dev/null @@ -1,63 +0,0 @@ -#search, #week-selector { - background-color: inherit; - box-shadow: inherit; -} - -#search { - border-bottom: 1px solid black; - position: absolute; -} - -#search .top-bar, #week-selector .week-wrapper { - max-width: inherit; -} - -#search input[type="search"] { - background-color: inherit; - color: black; - font-weight: bold; -} - -#search .fav { - display: none !important; -} - -#search #overflow-button { - display: none; -} - -#week-selector .week-wrapper { - display: block; -} - -#week-selector button { - display: none; -} - -#week-selector .current { - color: black; - padding: 16px; - font-size: 1.1em; - float: right; -} - -#search-space-filler { - display: none; -} - -.mdl-menu__container { - display: none !important; -} - -#schedule { - padding-top: 16px; - width: 100%; -} - -.no-print { - display: none; -} - -.print { - display: initial; -} diff --git a/src/client/static/stylesheets/style.css b/src/client/static/stylesheets/style.css deleted file mode 100644 index 830b007..0000000 --- a/src/client/static/stylesheets/style.css +++ /dev/null @@ -1,392 +0,0 @@ -* { - box-sizing: border-box; -} - -html, body { - margin: 0; - font-family: 'Roboto', sans-serif; -} - -.other { - color: gray; - font-style: italic; - margin-left: 5px; -} - -#search { - z-index: 2; - background-color: #F44336; - margin: 0 auto; - width: 100%; - position: fixed; - box-shadow: 0 0.5px 1.5px rgba(0,0,0,0.06), 0 0.5px 1px rgba(0,0,0,0.12); -} - -#search .top-bar { - position: relative; - margin: 0 auto; - max-width: 600px; - padding: 10px; - display: flex; -} - -#search .input-wrapper { - position: relative; - flex-grow: 1; - color: #FFFFFF; -} - -#search input[type='search'] { - display: block; - background-color: #f6695e; - color: inherit; - border-radius: 2px; - width: 100%; - display: block; - outline: none; - border: 0; - padding: 16px; - font-size: 16px; - transition: box-shadow 200ms ease-in-out; -} - -#search input[type='search']:focus { - background-color: #FFFFFF; - color: #212121; - box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24); -} - -#search input[type='search']:focus + button { - color: #212121; -} - -input[type="search"]::-webkit-search-decoration, -input[type="search"]::-webkit-search-cancel-button, -input[type="search"]::-webkit-search-results-button, -input[type="search"]::-webkit-search-results-decoration { - display: none; -} - -input[type="search"]::-ms-clear { - width: 0; - height: 0; -} - -button::-moz-focus-inner { - border: 0; -} - - -/* WebKit, Blink, Edge */ -input::-webkit-input-placeholder { - color: #FFCDD2; -} -input:focus::-webkit-input-placeholder { - color: #757575; -} - -/* Mozilla Firefox 4 to 18 */ -input:-moz-placeholder { - color: #FFCDD2; - opacity: 1; -} -input:focus:-moz-placeholder { - color: #757575; -} - -/* Mozilla Firefox 19+ */ -input::-moz-placeholder { - color: #FFCDD2; - opacity: 1; -} -input:focus::-moz-placeholder { - color: #757575; -} - -/* Internet Explorer 10-11 */ -input:-ms-input-placeholder { - color: #FFCDD2; -} -input:focus:-ms-input-placeholder { - color: #757575; -} - -li:hover { - background-color: lightgray; - cursor: pointer; -} - -.selected { - background-color: lightgray; -} - -#schedule { - overflow: auto; -} - -body.searched #search-space-filler { - height: 70px; -} - -.autocomplete-wrapper { - background-color: white; -} - -.autocomplete { - max-width: 600px; - margin: 0 auto; - padding: 0; -} - -.autocomplete li { - list-style: none; - padding: 10px; -} - -#week-selector { - z-index: 1; - background-color: #F44336; - box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23); - color: white; -} - -#week-selector .week-wrapper { - max-width: 600px; - padding: 10px !important; - margin: 0 auto; - display: flex; - -js-display: flex; - padding: 10px 0; -} - -#week-selector .current { - display: flex; - flex-grow: 1; - align-items: center; - justify-content: center; -} - -#week-selector .current.changed { - font-weight: bold; -} - -#week-selector button { - background: transparent; - color: white; - border: 0px; - padding: 5px 10px; - border-radius: 2px; -} - -input { - -webkit-appearance: none; -} - -#search .fav { - position: absolute; - font-size: 1.8em; - color: inherit; - right: 8.5px; - top: 8.5px; - border: 0; - padding: 4px; - border-radius: 2px; - background: none; - - display: none; -} - -body.searched #search .fav { - display: block; -} - -#week-selector button:focus, #search #overflow-button:focus, #search .fav:focus { - outline: none; - background-color: #D32F2F; -} - -#search #overflow-button { - background: none; - border: none; - padding: 3px 9px; - color: white; - border-radius: 2px; -} - -.hidden { - display: none !important; -} - -ul a { - color: inherit; - text-decoration: none; -} - -#search .title { - display: none; -} - -body:not(.no-input) { - overflow-y: scroll; -} - -body.no-input #week-selector { - display: none; -} - -@media screen and (min-height: 400px) { - body.no-input { - background-color: #ececec; - } - - body.no-input #search { - height: 100%; - background-color: #ececec; - box-shadow: none; - } - - body.no-input #search button { - display: none; - } - - body.no-input #search #overflow-button { - position: absolute; - display: block; - top: 0; - right: 0; - color: #757575; - } - - body.no-input #search .print-page { - display: none; - } - - body.no-input #search #overflow-button:focus { - background-color: inherit; - color: #212121; - } - - body.no-input #search .logo { - background-image: url(/icons/mml-logo.png); - background-position: center; - background-repeat: no-repeat; - background-size: contain; - height: 100px; - width: 100px; - - /* virtual center: http://javier.xyz/visual-center/ */ - transform: translate(-8%,-3%); - margin: 0 auto; - } - - body.no-input #search .title { - display: block; - font-size: 55px; - padding-bottom: 32px; - } - - body.no-input #search .title .text { - text-align: center; - line-height: 55px; - } - - body.no-input #search .top-bar { - position: static; - display: block; - margin-top: 50vh; - transform: translateY(-75%); - } - - body.no-input #search input[type='search'] { - background-color: #FFF; - } - - /* WebKit, Blink, Edge */ - body.no-input #search input::-webkit-input-placeholder { - color: #757575; - } - - /* Mozilla Firefox 4 to 18 */ - body.no-input #search input:-moz-placeholder { - color: #757575; - opacity: 1; - } - - /* Mozilla Firefox 19+ */ - body.no-input #search input::-moz-placeholder { - color: #757575; - opacity: 1; - } - - /* Internet Explorer 10-11 */ - body.no-input #search input:-ms-input-placeholder { - color: #757575; - } - - body.no-input .tooltip { - display: block; - position: absolute; - background-color: white; - padding: 15px; - margin: 32px 8px; - border-radius: 2px; - - left: 16px; - right: 16px; - } - - body.no-input .tooltip::before { - content: ''; - width: 24px; - height: 24px; - background-color: white; - top: -12px; - position: absolute; - transform: rotate(45deg); - z-index: -1; - } -} - -.tooltip { - display: none; -} - -.error { - text-align: center; - margin-top: 100px; - padding: 16px; -} - -body.week-selector-not-visible #search { - box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23); -} - -body.week-selector-not-visible #week-selector { - box-shadow: inherit; -} - -.print { - display: none; -} - -#notification { - max-width: 600px; - padding: 10px; - margin: 0 auto; -} - -#notification .box { - display: flex; - background-color: #e0e0e0; - padding: 8px; - border-radius: 2px; - align-items: center; -} - -#notification .text { - padding-left: 8px; -} - -.grow { - flex-grow: 1; -} diff --git a/src/client/style/_component-help-box.scss b/src/client/style/_component-help-box.scss new file mode 100644 index 0000000..e7457c2 --- /dev/null +++ b/src/client/style/_component-help-box.scss @@ -0,0 +1,23 @@ +.help-box { + position: relative; + margin-top: 32px; + + .arrow { + position: absolute; + background-color: white; + width: 32px; + height: 32px; + top: -16px; + left: 48px; + transform: rotate(45deg); + } + + .bubble { + position: relative; + background-color: white; + font-weight: bold; + margin: 16px; + padding: 16px; + border-radius: 4px; + } +} diff --git a/src/client/style/_component-search.scss b/src/client/style/_component-search.scss new file mode 100644 index 0000000..51d6b4a --- /dev/null +++ b/src/client/style/_component-search.scss @@ -0,0 +1,64 @@ +.search { + height: 54px; + position: relative; + + &-overflow { + border-radius: 2px; + background-color: white; + position: absolute; + width: 100%; + } + + &--has-focus { + box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24); + } + + &__results--has-results { + border-top: 1px #BDBDBD solid; + } + + &__icon-wrapper { + height: 54px; + padding: 15px; + svg { + height: 24px; + width: 24px; + } + } + + &__input-wrapper { + display: flex; + height: 54px; + + input { + border: 0; + background-color: transparent; + flex-grow: 1; + height: inherit; + padding: 16px; + padding-left: 0px; + font-size: 16px; + outline: none; + } + } + + &__results { + transition: 0.1s ease-in-out min-height; + overflow: hidden; + } + + &__result { + display: flex; + + &--selected { + background-color: lightgray; + } + + &__text { + padding: 15px; + padding-left: 0px; + font-size: 16px; + transform: translate(0, 3px); + } + } +} diff --git a/src/client/style/_page-index.scss b/src/client/style/_page-index.scss new file mode 100644 index 0000000..c09f996 --- /dev/null +++ b/src/client/style/_page-index.scss @@ -0,0 +1,21 @@ +.page-index { + background-color: #ececec; + padding-top: 24vh; + height: 100vh; + + .container { + max-width: 600px; + margin: 0 auto; + padding: 8px; + + img { + display: block; + margin: 0 auto; + } + + .search { + z-index: 1; // Position search above help-box + margin-top: 64px; + } + } +} diff --git a/src/client/style/_page-user.scss b/src/client/style/_page-user.scss new file mode 100644 index 0000000..395e492 --- /dev/null +++ b/src/client/style/_page-user.scss @@ -0,0 +1,11 @@ +.page-user { + .menu { + background-color: #F44336; + + &-container { + max-width: 600px; + margin: 0 auto; + padding: 8px; + } + } +} diff --git a/src/client/style/index.scss b/src/client/style/index.scss new file mode 100644 index 0000000..763a329 --- /dev/null +++ b/src/client/style/index.scss @@ -0,0 +1,14 @@ +* { + box-sizing: border-box; +} + +body { + font-family: 'Roboto'; + margin: 0; +} + +@import "page-index"; +@import "page-user"; + +@import "component-search"; +@import "component-help-box"; diff --git a/src/client/views/index.jade b/src/client/views/index.jade index 540fd42..9e9d713 100644 --- a/src/client/views/index.jade +++ b/src/client/views/index.jade @@ -1,51 +1,12 @@ extends layout -block variables - - var bodyStyle = 'opacity: 0;'; - block head - link(rel='stylesheet', href='/stylesheets/style.css') - link(rel='stylesheet', href='/stylesheets/print.css', media='print') - link(rel='stylesheet', href='https://fonts.googleapis.com/icon?family=Material+Icons') - link(rel='stylesheet', href='/components/material-design-lite/material.min.css') - script(defer='', src='/components/material-design-lite/material.min.js') + link(rel='stylesheet', href='/bundle.css') block content - form#search - .top-bar - .title - .logo - .text Rooster - .input-wrapper - input(type='search', placeholder='Zoeken', autocomplete='off') - button.material-icons.fav(tabindex='0', type='button')  - .tooltip - span Voer hier een <strong>docentafkorting</strong>, <strong>klas</strong>, <strong>leerlingnummer</strong> of <strong>lokaalnummer</strong> in. - button#overflow-button(type='button') - i.material-icons  - - ul.mdl-menu.mdl-menu--bottom-right.mdl-js-menu.mdl-js-ripple-effect(for='overflow-button') - a(href='http://www.meetingpointmco.nl/Roosters-AL/doc/basisroosters/default.htm') - li.mdl-menu__item Basis rooster gebruiken - a(href='http://www.meetingpointmco.nl/Roosters-AL/doc/') - li.mdl-menu__item Oud rooster gebruiken - a(href='javascript:window.print()').print-page - li.mdl-menu__item.mdl-menu__item--full-bleed-divider#print-page Pagina printen - li.mdl-menu__item(disabled) Gemaakt door Noah Loomans - .autocomplete-wrapper - ul.autocomplete - #week-selector - #search-space-filler - .week-wrapper - button(type='button').material-icons  - span.current - span.no-print Loading... - span.print - button(type='button').material-icons  - - #schedule + #root block scripts script. - !{flagsStr}!{usersStr}!{validWeekNumbersStr} + !{usersStr} script(src='/bundle.js') diff --git a/src/server/routes/getSchedule.js b/src/server/routes/getSchedule.js index 7850918..f6bc256 100644 --- a/src/server/routes/getSchedule.js +++ b/src/server/routes/getSchedule.js @@ -1,76 +1,70 @@ -const express = require('express') -const router = express.Router() -const request = require('request') -const iconv = require('iconv-lite') -const webshot = require('webshot') +const express = require('express'); -const getUserIndex = require('../lib/getUserIndex') -const getURLOfUser = require('../lib/getURLOfUser') +const router = express.Router(); +const request = require('request'); +const iconv = require('iconv-lite'); +const webshot = require('webshot'); + +const getUserIndex = require('../lib/getUserIndex'); +const getURLOfUser = require('../lib/getURLOfUser'); // copied from http://www.meetingpointmco.nl/Roosters-AL/doc/dagroosters/untisscripts.js, // were using the same code as they do to be sure that we always get the same // week number. -function getWeekNumber (target) { - const dayNr = (target.getDay() + 6) % 7 - target.setDate(target.getDate() - dayNr + 3) - const firstThursday = target.valueOf() - target.setMonth(0, 1) +function getWeekNumber(target) { + const dayNr = (target.getDay() + 6) % 7; + // eslint-disable-next-line + target.setDate(target.getDate() - dayNr + 3); + const firstThursday = target.valueOf(); + target.setMonth(0, 1); if (target.getDay() !== 4) { - target.setMonth(0, 1 + ((4 - target.getDay()) + 7) % 7) + // eslint-disable-next-line + target.setMonth(0, 1 + ((4 - target.getDay()) + 7) % 7); } - return 1 + Math.ceil((firstThursday - target) / 604800000) + return 1 + Math.ceil((firstThursday - target) / 604800000); } -router.get('/:type/:value.png', function (req, res, next) { - port = process.env.PORT || 3000; - const { type, value } = req.params - const stream = webshot( - `http://localhost:${port}/get/${type}/${value}`, - { customCSS: "body { background-color: white; }" } - ) - stream.pipe(res) -}) - -router.get('/:type/:value.jpg', function (req, res, next) { - port = process.env.PORT || 3000; - const { type, value } = req.params - const stream = webshot( - `http://localhost:${port}/get/${type}/${value}`, - { customCSS: "body { background-color: white; }", streamType: 'jpg' } - ) - stream.pipe(res) -}) +// router.get('/:type/:value.png', (req, res) => { +// const port = process.env.PORT || 3000; +// const { type, value } = req.params; +// const stream = webshot( +// `http://localhost:${port}/get/${type}/${value}`, +// { customCSS: 'body { background-color: white; }' }, +// ); +// stream.pipe(res); +// }); -router.get('/:type/:value', function (req, res, next) { - getUserIndex().then(users => { - const { type, value } = req.params - let { week } = req.query +router.get('/:type/:value', (req, res, next) => { + getUserIndex().then((users) => { + const { type, value } = req.params; + let { week } = req.query; const user = - users.filter(user => user.type === type && user.value === value)[0] + users.filter(user_ => user_.type === type && user_.value === value)[0]; if (!user) { - next(new Error(`${type}${value} is not in the user index.`)) + next(new Error(`${type}${value} is not in the user index.`)); } if (!week) { - week = getWeekNumber(new Date()) + week = getWeekNumber(new Date()); } - const { index } = user + const { index } = user; - const url = getURLOfUser(type, index, week) + const url = getURLOfUser(type, index, week); - request(url, { encoding: null }, function (err, data) { + request(url, { encoding: null }, (err, data) => { if (err) { - next(err) - return + next(err); + return; } - const utf8Body = iconv.decode(data.body, 'ISO-8859-1') - res.status(data.statusCode).end(utf8Body) - }) - }) -}) + const utf8Body = iconv.decode(data.body, 'ISO-8859-1'); + + res.status(data.statusCode).end(utf8Body); + }); + }); +}); -module.exports = router +module.exports = router; |