aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorNoah Loomans <noahloomans@gmail.com>2017-09-13 16:28:53 +0200
committerNoah Loomans <noahloomans@gmail.com>2017-09-13 16:28:53 +0200
commit3fb86482404e11942cd83c3500a297a3991db0e4 (patch)
treec5e7261de72c2b7f871580784525d06d036a6219 /src
parent5aac32f72eca8c66e879583ce653d07bb3c7370f (diff)
Restructure project
Diffstat (limited to 'src')
-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.js78
-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/static/.well-known/keybase.txt54
-rw-r--r--src/client/static/apple-touch-icon.pngbin0 -> 6447 bytes
-rw-r--r--src/client/static/browserconfig.xml9
-rw-r--r--src/client/static/favicon-16x16.pngbin0 -> 1293 bytes
-rw-r--r--src/client/static/favicon-32x32.pngbin0 -> 2103 bytes
-rw-r--r--src/client/static/favicon.icobin0 -> 15086 bytes
-rw-r--r--src/client/static/icons/mml-logo.pngbin0 -> 12508 bytes
-rw-r--r--src/client/static/icons/res/mipmap-hdpi/ic_launcher.pngbin0 -> 6503 bytes
-rw-r--r--src/client/static/icons/res/mipmap-mdpi/ic_launcher.pngbin0 -> 3854 bytes
-rw-r--r--src/client/static/icons/res/mipmap-xhdpi/ic_launcher.pngbin0 -> 9631 bytes
-rw-r--r--src/client/static/icons/res/mipmap-xxhdpi/ic_launcher.pngbin0 -> 15315 bytes
-rw-r--r--src/client/static/icons/res/mipmap-xxxhdpi/ic_launcher.pngbin0 -> 23445 bytes
-rw-r--r--src/client/static/manifest.beta.webmanifest29
-rw-r--r--src/client/static/manifest.webmanifest29
-rw-r--r--src/client/static/mstile-150x150.pngbin0 -> 3995 bytes
-rw-r--r--src/client/static/safari-pinned-tab.svg34
-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/static/sw.js29
-rw-r--r--src/client/static/untisinfo.css11
-rw-r--r--src/client/views/error.jade6
-rw-r--r--src/client/views/index.jade51
-rw-r--r--src/client/views/layout.jade25
-rw-r--r--src/client/views/redirect.jade47
-rw-r--r--src/server/app.js62
-rwxr-xr-xsrc/server/bin/www60
-rw-r--r--src/server/lib/getMeetingpointData.js83
-rw-r--r--src/server/lib/getURLOfUser.js8
-rw-r--r--src/server/lib/getUserIndex.js85
-rw-r--r--src/server/routes/getSchedule.js55
-rw-r--r--src/server/routes/index.js31
-rw-r--r--src/server/routes/manifest.js19
-rw-r--r--src/server/routes/opensearch.js12
47 files changed, 1981 insertions, 0 deletions
diff --git a/src/client/javascript/analytics.js b/src/client/javascript/analytics.js
new file mode 100644
index 0000000..a93c8a4
--- /dev/null
+++ b/src/client/javascript/analytics.js
@@ -0,0 +1,35 @@
+/* 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
new file mode 100644
index 0000000..61f400a
--- /dev/null
+++ b/src/client/javascript/autocomplete.js
@@ -0,0 +1,87 @@
+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
new file mode 100644
index 0000000..fbeab74
--- /dev/null
+++ b/src/client/javascript/browserFixToolkit.js
@@ -0,0 +1,12 @@
+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
new file mode 100644
index 0000000..92c87f7
--- /dev/null
+++ b/src/client/javascript/favorite.js
@@ -0,0 +1,79 @@
+/* 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
new file mode 100644
index 0000000..3a072a1
--- /dev/null
+++ b/src/client/javascript/featureDetect.js
@@ -0,0 +1,29 @@
+/* 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
new file mode 100644
index 0000000..17cb539
--- /dev/null
+++ b/src/client/javascript/frontpage.js
@@ -0,0 +1,23 @@
+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
new file mode 100644
index 0000000..0d125cb
--- /dev/null
+++ b/src/client/javascript/main.js
@@ -0,0 +1,71 @@
+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
new file mode 100644
index 0000000..38ad66d
--- /dev/null
+++ b/src/client/javascript/schedule.js
@@ -0,0 +1,78 @@
+/* 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()
+ search.updateDom(selectedUser)
+ } else if (VALID_WEEK_NUMBERS.indexOf(week) === -1) {
+ self._handleError({ target: { status: 404 } });
+ return
+ } 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
new file mode 100644
index 0000000..afee979
--- /dev/null
+++ b/src/client/javascript/scrollSnap.js
@@ -0,0 +1,59 @@
+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
new file mode 100644
index 0000000..96413b0
--- /dev/null
+++ b/src/client/javascript/search.js
@@ -0,0 +1,95 @@
+/* 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
new file mode 100644
index 0000000..17ab7c8
--- /dev/null
+++ b/src/client/javascript/url.js
@@ -0,0 +1,67 @@
+/* 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
new file mode 100644
index 0000000..d4e7f2a
--- /dev/null
+++ b/src/client/javascript/weekSelector.js
@@ -0,0 +1,99 @@
+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
new file mode 100644
index 0000000..59b80db
--- /dev/null
+++ b/src/client/javascript/zoom.js
@@ -0,0 +1,30 @@
+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/static/.well-known/keybase.txt b/src/client/static/.well-known/keybase.txt
new file mode 100644
index 0000000..7e11526
--- /dev/null
+++ b/src/client/static/.well-known/keybase.txt
@@ -0,0 +1,54 @@
+==================================================================
+https://keybase.io/nloomans
+--------------------------------------------------------------------
+
+I hereby claim:
+
+ * I am an admin of https://rooster.hetmml.nl
+ * I am nloomans (https://keybase.io/nloomans) on keybase.
+ * I have a public key ASCCV4aRFiMkEv7inJTf34RgxZ6IK0wQ-wTH2ZfSIu3OzAo
+
+To do so, I am signing this object:
+
+{
+ "body": {
+ "key": {
+ "eldest_kid": "0101bbdb28841b169de6538a51d17ca94b30088ba2914e56fd19121eec05f7a389cc0a",
+ "host": "keybase.io",
+ "kid": "01208257869116232412fee29c94dfdf8460c59e882b4c10fb04c7d997d222edcecc0a",
+ "uid": "7a52ddabf92293dd59f8fbf3774ea319",
+ "username": "nloomans"
+ },
+ "service": {
+ "hostname": "rooster.hetmml.nl",
+ "protocol": "https:"
+ },
+ "type": "web_service_binding",
+ "version": 1
+ },
+ "client": {
+ "name": "keybase.io go client",
+ "version": "1.0.20"
+ },
+ "ctime": 1492017398,
+ "expire_in": 504576000,
+ "merkle_root": {
+ "ctime": 1492017367,
+ "hash": "463e597079ce3829ccc1f1aa7b15533c0848f9e13cdb55407af490a87bf4ac1b2d64e8235518ada07d93003b889157b576aad02eda294ccd594dc0dcbf8862ef",
+ "seqno": 1015311
+ },
+ "prev": "36959cd282a98f651138068f8695b07480a016f02ba99a0acbde277e0cf4ca30",
+ "seqno": 19,
+ "tag": "signature"
+}
+
+which yields the signature:
+
+hKRib2R5hqhkZXRhY2hlZMOpaGFzaF90eXBlCqNrZXnEIwEggleGkRYjJBL+4pyU39+EYMWeiCtMEPsEx9mX0iLtzswKp3BheWxvYWTFAvt7ImJvZHkiOnsia2V5Ijp7ImVsZGVzdF9raWQiOiIwMTAxYmJkYjI4ODQxYjE2OWRlNjUzOGE1MWQxN2NhOTRiMzAwODhiYTI5MTRlNTZmZDE5MTIxZWVjMDVmN2EzODljYzBhIiwiaG9zdCI6ImtleWJhc2UuaW8iLCJraWQiOiIwMTIwODI1Nzg2OTExNjIzMjQxMmZlZTI5Yzk0ZGZkZjg0NjBjNTllODgyYjRjMTBmYjA0YzdkOTk3ZDIyMmVkY2VjYzBhIiwidWlkIjoiN2E1MmRkYWJmOTIyOTNkZDU5ZjhmYmYzNzc0ZWEzMTkiLCJ1c2VybmFtZSI6Im5sb29tYW5zIn0sInNlcnZpY2UiOnsiaG9zdG5hbWUiOiJyb29zdGVyLmhldG1tbC5ubCIsInByb3RvY29sIjoiaHR0cHM6In0sInR5cGUiOiJ3ZWJfc2VydmljZV9iaW5kaW5nIiwidmVyc2lvbiI6MX0sImNsaWVudCI6eyJuYW1lIjoia2V5YmFzZS5pbyBnbyBjbGllbnQiLCJ2ZXJzaW9uIjoiMS4wLjIwIn0sImN0aW1lIjoxNDkyMDE3Mzk4LCJleHBpcmVfaW4iOjUwNDU3NjAwMCwibWVya2xlX3Jvb3QiOnsiY3RpbWUiOjE0OTIwMTczNjcsImhhc2giOiI0NjNlNTk3MDc5Y2UzODI5Y2NjMWYxYWE3YjE1NTMzYzA4NDhmOWUxM2NkYjU1NDA3YWY0OTBhODdiZjRhYzFiMmQ2NGU4MjM1NTE4YWRhMDdkOTMwMDNiODg5MTU3YjU3NmFhZDAyZWRhMjk0Y2NkNTk0ZGMwZGNiZjg4NjJlZiIsInNlcW5vIjoxMDE1MzExfSwicHJldiI6IjM2OTU5Y2QyODJhOThmNjUxMTM4MDY4Zjg2OTViMDc0ODBhMDE2ZjAyYmE5OWEwYWNiZGUyNzdlMGNmNGNhMzAiLCJzZXFubyI6MTksInRhZyI6InNpZ25hdHVyZSJ9o3NpZ8RAcP5FuvbGM9nXBzWqChr9zdj452IpBzrVbd6YvcktLyKjaUaRg51BOWsyHmYQ+uxmZ2ZCUI6xZbbJ1SIAnWqvC6hzaWdfdHlwZSCkaGFzaIKkdHlwZQildmFsdWXEING7Z+BlY2sOTnQqQJo/PUBashy75VL9UU4tGIEvMXbco3RhZ80CAqd2ZXJzaW9uAQ==
+
+And finally, I am proving ownership of this host by posting or
+appending to this document.
+
+View my publicly-auditable identity here: https://keybase.io/nloomans
+
+==================================================================
diff --git a/src/client/static/apple-touch-icon.png b/src/client/static/apple-touch-icon.png
new file mode 100644
index 0000000..5adfc69
--- /dev/null
+++ b/src/client/static/apple-touch-icon.png
Binary files differ
diff --git a/src/client/static/browserconfig.xml b/src/client/static/browserconfig.xml
new file mode 100644
index 0000000..b3930d0
--- /dev/null
+++ b/src/client/static/browserconfig.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<browserconfig>
+ <msapplication>
+ <tile>
+ <square150x150logo src="/mstile-150x150.png"/>
+ <TileColor>#da532c</TileColor>
+ </tile>
+ </msapplication>
+</browserconfig>
diff --git a/src/client/static/favicon-16x16.png b/src/client/static/favicon-16x16.png
new file mode 100644
index 0000000..1df47d3
--- /dev/null
+++ b/src/client/static/favicon-16x16.png
Binary files differ
diff --git a/src/client/static/favicon-32x32.png b/src/client/static/favicon-32x32.png
new file mode 100644
index 0000000..36cd5da
--- /dev/null
+++ b/src/client/static/favicon-32x32.png
Binary files differ
diff --git a/src/client/static/favicon.ico b/src/client/static/favicon.ico
new file mode 100644
index 0000000..c201043
--- /dev/null
+++ b/src/client/static/favicon.ico
Binary files differ
diff --git a/src/client/static/icons/mml-logo.png b/src/client/static/icons/mml-logo.png
new file mode 100644
index 0000000..fa5ae11
--- /dev/null
+++ b/src/client/static/icons/mml-logo.png
Binary files differ
diff --git a/src/client/static/icons/res/mipmap-hdpi/ic_launcher.png b/src/client/static/icons/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..514ad14
--- /dev/null
+++ b/src/client/static/icons/res/mipmap-hdpi/ic_launcher.png
Binary files differ
diff --git a/src/client/static/icons/res/mipmap-mdpi/ic_launcher.png b/src/client/static/icons/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..59bab1d
--- /dev/null
+++ b/src/client/static/icons/res/mipmap-mdpi/ic_launcher.png
Binary files differ
diff --git a/src/client/static/icons/res/mipmap-xhdpi/ic_launcher.png b/src/client/static/icons/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..908a6e8
--- /dev/null
+++ b/src/client/static/icons/res/mipmap-xhdpi/ic_launcher.png
Binary files differ
diff --git a/src/client/static/icons/res/mipmap-xxhdpi/ic_launcher.png b/src/client/static/icons/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..f12048f
--- /dev/null
+++ b/src/client/static/icons/res/mipmap-xxhdpi/ic_launcher.png
Binary files differ
diff --git a/src/client/static/icons/res/mipmap-xxxhdpi/ic_launcher.png b/src/client/static/icons/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..7a0462e
--- /dev/null
+++ b/src/client/static/icons/res/mipmap-xxxhdpi/ic_launcher.png
Binary files differ
diff --git a/src/client/static/manifest.beta.webmanifest b/src/client/static/manifest.beta.webmanifest
new file mode 100644
index 0000000..a1fdd92
--- /dev/null
+++ b/src/client/static/manifest.beta.webmanifest
@@ -0,0 +1,29 @@
+{
+ "name": "BETA Metis Rooster",
+ "short_name": "BETA Rooster",
+ "start_url": "/",
+ "display": "standalone",
+ "background_color": "#ececec",
+ "description": "Een verbeterde rooster pagina voor het metis",
+ "icons": [{
+ "src": "/icons/res/mipmap-mdpi/ic_launcher.png",
+ "sizes": "48x48",
+ "type": "image/png"
+ }, {
+ "src": "/icons/res/mipmap-hdpi/ic_launcher.png",
+ "sizes": "72x72",
+ "type": "image/png"
+ }, {
+ "src": "/icons/res/mipmap-xhdpi/ic_launcher.png",
+ "sizes": "96x96",
+ "type": "image/png"
+ }, {
+ "src": "/icons/res/mipmap-xxhdpi/ic_launcher.png",
+ "sizes": "144x144",
+ "type": "image/png"
+ }, {
+ "src": "/icons/res/mipmap-xxxhdpi/ic_launcher.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ }]
+}
diff --git a/src/client/static/manifest.webmanifest b/src/client/static/manifest.webmanifest
new file mode 100644
index 0000000..d33ee8e
--- /dev/null
+++ b/src/client/static/manifest.webmanifest
@@ -0,0 +1,29 @@
+{
+ "name": "Metis Rooster",
+ "short_name": "Rooster",
+ "start_url": "/",
+ "display": "standalone",
+ "background_color": "#ececec",
+ "description": "Een verbeterde rooster pagina voor het metis",
+ "icons": [{
+ "src": "/icons/res/mipmap-mdpi/ic_launcher.png",
+ "sizes": "48x48",
+ "type": "image/png"
+ }, {
+ "src": "/icons/res/mipmap-hdpi/ic_launcher.png",
+ "sizes": "72x72",
+ "type": "image/png"
+ }, {
+ "src": "/icons/res/mipmap-xhdpi/ic_launcher.png",
+ "sizes": "96x96",
+ "type": "image/png"
+ }, {
+ "src": "/icons/res/mipmap-xxhdpi/ic_launcher.png",
+ "sizes": "144x144",
+ "type": "image/png"
+ }, {
+ "src": "/icons/res/mipmap-xxxhdpi/ic_launcher.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ }]
+}
diff --git a/src/client/static/mstile-150x150.png b/src/client/static/mstile-150x150.png
new file mode 100644
index 0000000..5e381e6
--- /dev/null
+++ b/src/client/static/mstile-150x150.png
Binary files differ
diff --git a/src/client/static/safari-pinned-tab.svg b/src/client/static/safari-pinned-tab.svg
new file mode 100644
index 0000000..97ce8bf
--- /dev/null
+++ b/src/client/static/safari-pinned-tab.svg
@@ -0,0 +1,34 @@
+<?xml version="1.0" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
+ "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
+<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
+ width="16.000000pt" height="16.000000pt" viewBox="0 0 16.000000 16.000000"
+ preserveAspectRatio="xMidYMid meet">
+<metadata>
+Created by potrace 1.11, written by Peter Selinger 2001-2013
+</metadata>
+<g transform="translate(0.000000,16.000000) scale(0.012030,-0.012030)"
+fill="#000000" stroke="none">
+<path d="M498 1233 c-10 -20 -1 -650 11 -690 18 -64 84 -154 141 -192 82 -55
+144 -66 385 -66 l209 0 1 478 c0 262 0 477 0 477 -1 0 -20 -18 -44 -40 -24
+-22 -64 -52 -89 -67 -44 -26 -49 -27 -231 -28 -102 -1 -202 3 -221 8 -47 12
+-107 54 -135 95 -13 18 -25 30 -27 25z"/>
+<path d="M405 917 c-95 -24 -193 -97 -250 -187 -77 -122 -83 -287 -14 -421 30
+-60 113 -142 173 -173 171 -86 353 -62 489 64 48 45 50 49 27 50 -14 0 -41
+-13 -59 -30 -33 -27 -131 -86 -131 -77 0 2 14 32 30 66 17 35 30 64 28 65 -42
+30 -86 48 -120 49 -42 2 -42 2 -43 42 -1 31 -27 95 -39 95 -1 0 -2 -30 -1 -67
+l0 -68 -73 -3 c-84 -3 -84 -2 -97 105 l-7 62 77 -1 c42 0 78 2 81 5 2 2 2 11
+-2 20 -4 12 -18 16 -48 16 -22 -1 -56 -1 -74 0 l-34 1 6 53 c4 28 10 66 14 82
+8 29 11 30 62 30 43 0 54 3 56 18 2 14 -6 17 -41 17 -24 0 -46 3 -48 8 -7 10
+39 102 65 129 22 24 32 50 20 56 -4 2 -25 -1 -47 -6z m-58 -119 l-32 -68 -52
+0 c-29 0 -53 4 -53 8 0 15 74 80 120 106 24 14 45 24 46 23 1 -1 -12 -32 -29
+-69z m-52 -153 c-4 -27 -8 -62 -10 -76 -1 -15 -5 -31 -8 -35 -5 -9 -133 -11
+-142 -2 -7 7 15 108 30 138 12 23 19 25 75 25 l62 1 -7 -51z m-14 -168 c0 -7
+2 -19 3 -27 2 -8 6 -41 10 -72 l7 -58 -61 0 -61 0 -19 43 c-10 23 -21 61 -25
+84 l-6 41 76 1 c52 0 75 -3 76 -12z m65 -257 c18 -35 32 -65 31 -67 -14 -13
+-167 102 -167 126 0 4 23 7 52 7 l51 -1 33 -65z m145 58 c3 -5 5 -41 4 -81 -1
+-70 -2 -72 -26 -69 -13 1 -32 13 -42 25 -14 19 -67 119 -67 129 0 7 126 3 131
+-4z m164 0 c8 -7 -35 -94 -61 -125 -11 -12 -28 -23 -39 -25 -18 -3 -20 3 -20
+72 -1 41 0 77 1 80 4 8 111 6 119 -2z"/>
+</g>
+</svg>
diff --git a/src/client/static/stylesheets/hello.css b/src/client/static/stylesheets/hello.css
new file mode 100644
index 0000000..edcbc92
--- /dev/null
+++ b/src/client/static/stylesheets/hello.css
@@ -0,0 +1,23 @@
+* {
+ 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
new file mode 100644
index 0000000..0e09533
--- /dev/null
+++ b/src/client/static/stylesheets/print.css
@@ -0,0 +1,63 @@
+#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
new file mode 100644
index 0000000..830b007
--- /dev/null
+++ b/src/client/static/stylesheets/style.css
@@ -0,0 +1,392 @@
+* {
+ 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/static/sw.js b/src/client/static/sw.js
new file mode 100644
index 0000000..bd43805
--- /dev/null
+++ b/src/client/static/sw.js
@@ -0,0 +1,29 @@
+/* global importScripts toolbox self */
+
+(global => {
+ 'use strict'
+
+ // Load the sw-toolbox library.
+ importScripts('/components/sw-toolbox/sw-toolbox.js')
+
+ // Ensure that our service worker takes control of the page as soon as possible.
+ global.addEventListener('install', event => event.waitUntil(global.skipWaiting()))
+ global.addEventListener('activate', event => event.waitUntil(global.clients.claim()))
+
+ toolbox.precache([
+ '/',
+ '/hello',
+ '/untisinfo.css',
+ '/javascripts/bundle.js',
+ '/stylesheets/style.css',
+ '/stylesheets/hello.css'
+ ])
+
+ toolbox.router.get('/', toolbox.fastest)
+ toolbox.router.get('/hello', toolbox.fastest)
+
+ toolbox.router.get('/javascripts/bundle.js', toolbox.fastest)
+ toolbox.router.get('/stylesheets/*', toolbox.fastest)
+ toolbox.router.get('/untisinfo.css', toolbox.fastest)
+ toolbox.router.get('/meetingpointProxy/*', toolbox.networkFirst)
+})(self)
diff --git a/src/client/static/untisinfo.css b/src/client/static/untisinfo.css
new file mode 100644
index 0000000..d74a7aa
--- /dev/null
+++ b/src/client/static/untisinfo.css
@@ -0,0 +1,11 @@
+html, body {
+ overflow: auto;
+ width: 100vw;
+ height: 100vh;
+ margin: 0;
+ -webkit-overflow-scrolling: touch;
+}
+
+center {
+ margin: 5px;
+}
diff --git a/src/client/views/error.jade b/src/client/views/error.jade
new file mode 100644
index 0000000..51ec12c
--- /dev/null
+++ b/src/client/views/error.jade
@@ -0,0 +1,6 @@
+extends layout
+
+block content
+ h1= message
+ h2= error.status
+ pre #{error.stack}
diff --git a/src/client/views/index.jade b/src/client/views/index.jade
new file mode 100644
index 0000000..540fd42
--- /dev/null
+++ b/src/client/views/index.jade
@@ -0,0 +1,51 @@
+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')
+
+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
+
+block scripts
+ script.
+ !{flagsStr}!{usersStr}!{validWeekNumbersStr}
+ script(src='/bundle.js')
diff --git a/src/client/views/layout.jade b/src/client/views/layout.jade
new file mode 100644
index 0000000..f7f9e1f
--- /dev/null
+++ b/src/client/views/layout.jade
@@ -0,0 +1,25 @@
+block variables
+ - var bodyStyle = '';
+
+doctype html
+html(lang='nl')
+ head
+ block head_top
+ if isBeta
+ title BETA Metis Rooster
+ else
+ title Metis Rooster
+ meta(name='theme-color',content='#F44336')
+ meta(name='viewport', content='width=device-width, initial-scale=1')
+ link(href='https://fonts.googleapis.com/css?family=Roboto', rel='stylesheet')
+ link(rel='manifest', href='/manifest.webmanifest')
+ link(rel="apple-touch-icon", sizes="120x120", href="/apple-touch-icon.png")
+ link(rel="icon", type="image/png", href="/favicon-32x32.png", sizes="32x32")
+ link(rel="icon", type="image/png", href="/favicon-16x16.png", sizes="16x16")
+ link(rel="mask-icon", href="/safari-pinned-tab.svg", color="#f44336")
+ block head
+ body(style=bodyStyle)
+ block content
+ script.
+ (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');ga('create', 'UA-83684754-1', 'auto');ga('send', 'pageview');
+ block scripts
diff --git a/src/client/views/redirect.jade b/src/client/views/redirect.jade
new file mode 100644
index 0000000..e895456
--- /dev/null
+++ b/src/client/views/redirect.jade
@@ -0,0 +1,47 @@
+extends layout
+
+block head
+ style.
+ body {
+ font-family: 'Roboto', sans-serif;
+ text-align: center;
+ margin: 32px;
+ }
+
+ .content {
+ max-width: 600px;
+ margin: 0 auto;
+ }
+
+ h1 {
+ color: #212121;
+ margin-bottom: 32px;
+ }
+
+ h2 {
+ color: #727272;
+ margin-bottom: 64px;
+ }
+
+ a, a:visited {
+ margin: 8px;
+ padding: 8px 16px;
+ background-color: #c84127;
+ color: white;
+ text-decoration: none;
+ font-weight: bold;
+ border-radius: 3px;
+ }
+
+ a:hover, a:focus, a:active {
+ background-color: #e45a3f;
+ }
+
+block content
+ .content
+ script document.body.style.opacity = 1
+ img(src='/icons/mml-logo.png')
+ h1 Er is iets mis gegaan tijdens het ophalen van de benodigde informatie
+ h2 Je kunt proberen door te gaan naar het oude rooster of Magister
+ a(href='http://www.meetingpointmco.nl/Roosters-AL/doc/') Oud rooster
+ a(href='http://msa.magister.net/') Magister
diff --git a/src/server/app.js b/src/server/app.js
new file mode 100644
index 0000000..36d7e26
--- /dev/null
+++ b/src/server/app.js
@@ -0,0 +1,62 @@
+const express = require('express')
+const path = require('path')
+const logger = require('morgan')
+const cookieParser = require('cookie-parser')
+const bodyParser = require('body-parser')
+const compression = require('compression')
+
+const routes = require('./routes/index')
+const getSchedule = require('./routes/getSchedule')
+const manifest = require('./routes/manifest')
+
+const app = express()
+
+app.use(compression())
+
+// view engine setup
+app.set('views', path.join(__dirname, '../client/views'))
+app.set('view engine', 'jade')
+
+app.use(logger('dev'))
+app.use(bodyParser.json())
+app.use(bodyParser.urlencoded({ extended: false }))
+app.use(cookieParser())
+
+app.use('/manifest.webmanifest', manifest)
+app.use(express.static(path.join(__dirname, '../client/static')))
+
+app.use('/', routes)
+app.use('/get', getSchedule)
+
+// catch 404 and forward to error handler
+app.use(function (req, res, next) {
+ const err = new Error('Not Found')
+ err.status = 404
+ next(err)
+})
+
+// error handlers
+
+// development error handler
+// will print stacktrace
+if (app.get('env') === 'development') {
+ app.use(function (err, req, res, next) {
+ res.status(err.status || 500)
+ res.render('error', {
+ message: err.message,
+ error: err
+ })
+ })
+}
+
+// production error handler
+// no stacktraces leaked to user
+app.use(function (err, req, res, next) {
+ res.status(err.status || 500)
+ res.render('error', {
+ message: err.message,
+ error: {}
+ })
+})
+
+module.exports = app
diff --git a/src/server/bin/www b/src/server/bin/www
new file mode 100755
index 0000000..545db41
--- /dev/null
+++ b/src/server/bin/www
@@ -0,0 +1,60 @@
+#!/usr/bin/env node
+
+const app = require('../app')
+const http = require('http')
+
+const port = normalizePort(process.env.PORT || '3000')
+const server = http.createServer(app)
+
+server.listen(port)
+server.on('error', error => onError(error, port))
+server.on('listening', _ => onListening(server))
+
+function normalizePort (val) {
+ const port = parseInt(val, 10)
+
+ if (isNaN(port)) {
+ // named pipe
+ return val
+ }
+
+ if (port >= 0) {
+ // port number
+ return port
+ }
+
+ return false
+}
+
+function onError (error, port) {
+ if (error.syscall !== 'listen') {
+ throw error
+ }
+
+ const bind = typeof port === 'string'
+ ? 'Pipe ' + port
+ : 'Port ' + port
+
+ // handle specific listen errors with friendly messages
+ switch (error.code) {
+ case 'EACCES':
+ console.error(bind + ' requires elevated privileges')
+ process.exit(1)
+ break
+ case 'EADDRINUSE':
+ console.error(bind + ' is already in use')
+ process.exit(1)
+ break
+ default:
+ throw error
+ }
+}
+
+function onListening (server) {
+ const addr = server.address()
+ if (typeof addr === 'string') {
+ console.log(`Listening on pipe ${addr}`)
+ } else {
+ console.log(`Listening on http://localhost:${addr.port}/`)
+ }
+}
diff --git a/src/server/lib/getMeetingpointData.js b/src/server/lib/getMeetingpointData.js
new file mode 100644
index 0000000..94cf36c
--- /dev/null
+++ b/src/server/lib/getMeetingpointData.js
@@ -0,0 +1,83 @@
+'use strict'
+
+const Promise = require('bluebird')
+const cheerio = require('cheerio')
+const _ = require('lodash')
+const request = Promise.promisify(require('request'))
+
+let meetingpointData
+let lastUpdate
+
+function getUsers (page) {
+ const script = page('script').eq(1).text()
+
+ const regexs = [/var classes = \[(.+)\];/, /var teachers = \[(.+)\];/, /var rooms = \[(.+)\];/, /var students = \[(.+)\];/]
+ const items = regexs.map(function (regex) {
+ return script.match(regex)[1].split(',').map(function (item) {
+ return item.replace(/"/g, '')
+ })
+ })
+
+ return []
+ .concat(items[0].map(function (item, index) {
+ return {
+ type: 'c',
+ value: item,
+ index: index
+ }
+ }))
+ .concat(items[1].map(function (item, index) {
+ return {
+ type: 't',
+ value: item,
+ index: index
+ }
+ }))
+ .concat(items[2].map(function (item, index) {
+ return {
+ type: 'r',
+ value: item,
+ index: index
+ }
+ }))
+ .concat(items[3].map(function (item, index) {
+ return {
+ type: 's',
+ value: item,
+ index: index
+ }
+ }))
+}
+
+function getValidWeekNumbers(page) {
+ const weekSelector = page('select[name="week"]');
+ const weekNumbers = _.map(weekSelector.children(), option => parseInt(option.attribs.value))
+
+ return weekNumbers;
+}
+
+function requestData() {
+ lastUpdate = new Date()
+
+ return request(`http://www.meetingpointmco.nl/Roosters-AL/doc/dagroosters/frames/navbar.htm`, { timeout: 5000 }).then((response) => {
+ const page = cheerio.load(response.body)
+ const users = getUsers(page)
+ const validWeekNumbers = getValidWeekNumbers(page)
+
+ meetingpointData = { users, validWeekNumbers }
+
+ return meetingpointData
+ })
+}
+
+function getMeetingpointData () {
+ if (lastUpdate == null || new Date() - lastUpdate > 10 * 60 * 1000) { // 10 minutes
+ return requestData()
+ } else if (!meetingpointData) {
+ return Promise.reject()
+ } else {
+ return Promise.resolve(meetingpointData)
+ }
+}
+
+module.exports = getMeetingpointData
diff --git a/src/server/lib/getURLOfUser.js b/src/server/lib/getURLOfUser.js
new file mode 100644
index 0000000..2de48e6
--- /dev/null
+++ b/src/server/lib/getURLOfUser.js
@@ -0,0 +1,8 @@
+const leftPad = require('left-pad') // I imported this just to piss you off ;)
+
+function getURLOfUser (type, index, week) {
+ return `http://www.meetingpointmco.nl/Roosters-AL/doc/dagroosters/` +
+ `${leftPad(week, 2, '0')}/${type}/${type}${leftPad(index + 1, 5, '0')}.htm`
+}
+
+module.exports = getURLOfUser
diff --git a/src/server/lib/getUserIndex.js b/src/server/lib/getUserIndex.js
new file mode 100644
index 0000000..db7daa8
--- /dev/null
+++ b/src/server/lib/getUserIndex.js
@@ -0,0 +1,85 @@
+'use strict'
+
+const Promise = require('bluebird')
+const cheerio = require('cheerio')
+const request = Promise.promisify(require('request'))
+
+let userIndex
+let lastUpdate
+
+function updateUserIndex () {
+ return new Promise(function (resolve, reject) {
+ process.stdout.write('Updating user index... ')
+ request(`http://www.meetingpointmco.nl/Roosters-AL/doc/dagroosters/frames/navbar.htm`)
+ .then(function (page) {
+ lastUpdate = new Date()
+ page = page.body
+
+ const $ = cheerio.load(page)
+ const $script = $('script').eq(1)
+ const scriptText = $script.text()
+
+ const regexs = [/var classes = \[(.+)];/, /var teachers = \[(.+)];/, /var rooms = \[(.+)];/, /var students = \[(.+)];/]
+ const items = regexs.map(function (regex) {
+ return scriptText.match(regex)[1].split(',').map(function (item) {
+ return item.replace(/"/g, '')
+ })
+ })
+
+ userIndex = ([]
+ .concat(items[0].map(function (item, index) {
+ return {
+ type: 'c',
+ value: item,
+ index: index
+ }
+ }))
+ .concat(items[1].map(function (item, index) {
+ return {
+ type: 't',
+ value: item,
+ index: index
+ }
+ }))
+ .concat(items[2].map(function (item, index) {
+ return {
+ type: 'r',
+ value: item,
+ index: index
+ }
+ }))
+ .concat(items[3].map(function (item, index) {
+ return {
+ type: 's',
+ value: item,
+ index: index
+ }
+ })))
+
+ process.stdout.write('done.\n')
+
+ resolve(userIndex)
+ })
+ .catch(error => {
+ process.stdout.write('failed.\n')
+ reject(error)
+ })
+ })
+}
+
+function getUserIndex () {
+ return new Promise((resolve, reject) => {
+ if (lastUpdate == null) {
+ updateUserIndex().then(resolve, reject)
+ } else if (new Date() - lastUpdate > 10 * 60 * 1000) { // 10 minutes
+ updateUserIndex().then(resolve, function () {
+ console.warn('Unable to update userIndex, using cached.')
+ resolve(userIndex)
+ })
+ } else {
+ resolve(userIndex)
+ }
+ })
+}
+
+module.exports = getUserIndex
diff --git a/src/server/routes/getSchedule.js b/src/server/routes/getSchedule.js
new file mode 100644
index 0000000..f6c3cb6
--- /dev/null
+++ b/src/server/routes/getSchedule.js
@@ -0,0 +1,55 @@
+const express = require('express')
+const router = express.Router()
+const request = require('request')
+const iconv = require('iconv-lite')
+
+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)
+ if (target.getDay() !== 4) {
+ target.setMonth(0, 1 + ((4 - target.getDay()) + 7) % 7)
+ }
+
+ return 1 + Math.ceil((firstThursday - target) / 604800000)
+}
+
+router.get('/:type/:value', function (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]
+
+ if (!user) {
+ next(new Error(`${type}${value} is not in the user index.`))
+ }
+
+ if (!week) {
+ week = getWeekNumber(new Date())
+ }
+
+ const { index } = user
+
+ const url = getURLOfUser(type, index, week)
+
+ request(url, { encoding: null }, function (err, data) {
+ if (err) {
+ next(err)
+ return
+ }
+
+ const utf8Body = iconv.decode(data.body, 'ISO-8859-1')
+ res.status(data.statusCode).end(utf8Body)
+ })
+ })
+})
+
+module.exports = router
diff --git a/src/server/routes/index.js b/src/server/routes/index.js
new file mode 100644
index 0000000..d2267ba
--- /dev/null
+++ b/src/server/routes/index.js
@@ -0,0 +1,31 @@
+'use strict'
+
+const express = require('express')
+const router = express.Router()
+const getMeetingpointData = require('../lib/getMeetingpointData')
+
+/* GET home page. */
+router.get(['/', '/s/*', '/t/*', '/r/*', '/c/*'], function (req, res, next) {
+ getMeetingpointData().then(data => {
+ const isBeta = process.env.BETA === '1'
+
+ let flags = []
+ if (isBeta) {
+ flags.push('BETA')
+ flags.push('NO_FEATURE_DETECT')
+ } else if (req.query.nfd != null) {
+ flags.push('NO_FEATURE_DETECT')
+ }
+
+ const flagsStr = `var FLAGS = ${JSON.stringify(flags)};`
+ const usersStr = `var USERS = ${JSON.stringify(data.users)};`
+ const validWeekNumbersStr = `var VALID_WEEK_NUMBERS = ${JSON.stringify(data.validWeekNumbers)}`
+
+ res.render('index', { flagsStr, usersStr, validWeekNumbersStr })
+ }).catch(function () {
+ console.error('Unable to get user info, emergency redirect!')
+ res.render('redirect')
+ })
+})
+
+module.exports = router
diff --git a/src/server/routes/manifest.js b/src/server/routes/manifest.js
new file mode 100644
index 0000000..b2ce55f
--- /dev/null
+++ b/src/server/routes/manifest.js
@@ -0,0 +1,19 @@
+'use strict'
+
+const express = require('express')
+const router = express.Router()
+const path = require('path')
+
+router.get('/', function (req, res, next) {
+ console.log('got a request')
+
+ const isBeta = process.env.BETA === '1'
+
+ if (isBeta) {
+ res.sendFile('manifest.beta.webmanifest', { root: path.join(__dirname, '../public') })
+ } else {
+ res.sendFile('manifest.webmanifest', { root: path.join(__dirname, '../public') })
+ }
+})
+
+module.exports = router
diff --git a/src/server/routes/opensearch.js b/src/server/routes/opensearch.js
new file mode 100644
index 0000000..c3e2e57
--- /dev/null
+++ b/src/server/routes/opensearch.js
@@ -0,0 +1,12 @@
+'use strict'
+
+const express = require('express')
+const router = express.Router()
+const path = require('path')
+
+router.get('/', function (req, res, next) {
+ res.setHeader('content-type', 'application/opensearchdescription+xml')
+ res.sendFile('opensearch.xml', { root: path.join(__dirname, '../public') })
+})
+
+module.exports = router