aboutsummaryrefslogtreecommitdiff
path: root/src/client
diff options
context:
space:
mode:
Diffstat (limited to 'src/client')
-rw-r--r--src/client/javascript/analytics.js35
-rw-r--r--src/client/javascript/autocomplete.js87
-rw-r--r--src/client/javascript/browserFixToolkit.js12
-rw-r--r--src/client/javascript/favorite.js79
-rw-r--r--src/client/javascript/featureDetect.js29
-rw-r--r--src/client/javascript/frontpage.js23
-rw-r--r--src/client/javascript/main.js71
-rw-r--r--src/client/javascript/schedule.js75
-rw-r--r--src/client/javascript/scrollSnap.js59
-rw-r--r--src/client/javascript/search.js95
-rw-r--r--src/client/javascript/url.js67
-rw-r--r--src/client/javascript/weekSelector.js99
-rw-r--r--src/client/javascript/zoom.js30
-rw-r--r--src/client/react/actions/search.js19
-rw-r--r--src/client/react/actions/view.js31
-rw-r--r--src/client/react/components/container/HelpBox.js30
-rw-r--r--src/client/react/components/container/Results.js38
-rw-r--r--src/client/react/components/container/Search.js142
-rw-r--r--src/client/react/components/container/View.js47
-rw-r--r--src/client/react/components/container/WeekSelector.js39
-rw-r--r--src/client/react/components/page/Index.js15
-rw-r--r--src/client/react/components/page/User.js47
-rw-r--r--src/client/react/components/presentational/IconFromUserType.js37
-rw-r--r--src/client/react/components/presentational/Loading.js5
-rw-r--r--src/client/react/components/presentational/Result.js24
-rw-r--r--src/client/react/components/presentational/Schedule.js22
-rw-r--r--src/client/react/index.js36
-rw-r--r--src/client/react/lib/extractSchedule.js10
-rw-r--r--src/client/react/lib/purifyWeek.js9
-rw-r--r--src/client/react/reducers.js10
-rw-r--r--src/client/react/reducers/search.js105
-rw-r--r--src/client/react/reducers/search.test.js229
-rw-r--r--src/client/react/reducers/view.js53
-rw-r--r--src/client/react/users.js66
-rw-r--r--src/client/static/stylesheets/hello.css23
-rw-r--r--src/client/static/stylesheets/print.css63
-rw-r--r--src/client/static/stylesheets/style.css392
-rw-r--r--src/client/style/_component-help-box.scss23
-rw-r--r--src/client/style/_component-search.scss64
-rw-r--r--src/client/style/_page-index.scss21
-rw-r--r--src/client/style/_page-user.scss11
-rw-r--r--src/client/style/index.scss14
-rw-r--r--src/client/views/index.jade45
43 files changed, 1150 insertions, 1281 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 = '&#xE838;'
-}
-
-self.delete = function () {
- window.localStorage.removeItem('fav')
-}
-
-self.updateDom = function (isFavorite) {
- if (isFavorite) {
- self._nodes.toggle.innerHTML = '&#xE838;'
- } else {
- self._nodes.toggle.innerHTML = '&#xE83A'
- }
-}
-
-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') &#xE83A;
- .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 &#xE5D4;
-
- 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 &#xE5C4;
- span.current
- span.no-print Loading...
- span.print
- button(type='button').material-icons &#xE5C8;
-
- #schedule
+ #root
block scripts
script.
- !{flagsStr}!{usersStr}!{validWeekNumbersStr}
+ !{usersStr}
script(src='/bundle.js')