├── static
├── robots.txt
├── dummy
├── screenshots
│ ├── events.png
│ ├── search.png
│ ├── dashboard.png
│ └── devrooms.png
├── sojourner-preview-2.png
├── sojourner-preview-3.png
├── sojourner-preview-s.jpg
└── .well-known
│ └── assetlinks.json
├── .tool-versions
├── functions
├── .gitignore
├── cors.json
├── logic
│ ├── Link.js
│ ├── Video.js
│ ├── Type.js
│ └── Event.js
├── sample.env
├── admin
│ ├── common.js
│ ├── users.js
│ └── favourites.js
├── auth.js
├── indexEvents.js
├── migrate
│ └── 001-migrateFavouritesToConference.js
├── package.json
├── store.js
├── index.js
├── stats
│ └── popularity.js
└── fetch
│ ├── sched.js
│ └── fosdem.js
├── .github
└── FUNDING.yml
├── init
├── initFixes.js
├── initStyle.js
├── index.js
├── initSentry.js
├── initFirebase.js
└── initServiceWorker.js
├── firestore.indexes.json
├── assets
├── campus.png
├── building-aw.png
├── building-h.png
├── building-j.png
├── building-k.png
├── building-u.png
├── dashboard.jpg
├── menu-logo.png
├── background-1.png
├── background-2.png
├── background-3.png
├── background-4.png
├── background-5.png
├── logo-toolbar.png
├── logo-small-inner.png
├── logo-small-outer.png
├── dashboard-desktop.png
├── sojourner-icon-112.png
├── sojourner-icon-192.png
├── sojourner-icon-224.png
├── sojourner-icon-512.png
├── sojourner-icon-56.png
├── sojourner-transparent.png
├── sojourner-icon-app-112.png
├── sojourner-icon-app-180.png
├── sojourner-icon-app-192.png
├── sojourner-icon-app-224.png
└── sojourner-icon-app-512.png
├── .firebaserc
├── logic
├── Link.js
├── Video.js
├── Room.js
├── Track.js
├── Building.js
├── Type.js
├── Day.js
├── RoomState.js
└── Event.js
├── pages
├── Edition.vue
├── AllEvents.vue
├── LiveEvents.vue
├── ConferenceTrackEvents.vue
├── Delete.vue
├── FavouriteEvents.vue
├── SharedEvents.vue
├── TypeTracksOrEvents.vue
├── BuildingMap.vue
├── CampusMap.vue
├── SearchEvents.vue
├── About.vue
├── Dashboard.vue
├── index.js
└── EventDetails.vue
├── firestore.rules
├── storage.rules
├── main.js
├── sample.env
├── components
├── Analytics.vue
├── Chat.vue
├── RoomState.vue
├── MenuItem.vue
├── Play.vue
├── FavouriteTrack.vue
├── TrackListPlain.vue
├── Favourite.vue
├── EventListPlain.vue
├── DayTabs.vue
├── ConferenceTrack.vue
├── BottomMenu.vue
├── TrackList.vue
├── Notification.vue
├── EventList.vue
├── Event.vue
├── PageTitle.vue
├── MainMenu.vue
├── Player.vue
├── MainToolbar.vue
└── LoginDialog.vue
├── vuetify.js
├── test
├── store
│ └── schedule.test.js
├── layout
│ └── App.test.js
└── resources
│ └── fosdem-2019.json
├── store
├── map.js
├── index.js
├── history.js
├── notification.js
├── cron.js
├── conference.js
├── player.js
├── state.js
├── meta.js
├── favourite.js
├── user.js
└── schedule.js
├── firebase.json
├── karma.conf.js
├── .eslintrc.js
├── preload.css
├── CHANGELOG.md
├── README.md
├── .gitignore
├── netlify.toml
├── index.html
├── config.js
├── package.json
├── layout
└── App.vue
└── webpack.config.js
/static/robots.txt:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.tool-versions:
--------------------------------------------------------------------------------
1 | nodejs 20.10.0
2 |
--------------------------------------------------------------------------------
/functions/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: loomchild
2 |
--------------------------------------------------------------------------------
/init/initFixes.js:
--------------------------------------------------------------------------------
1 | export default function () {
2 | }
3 |
--------------------------------------------------------------------------------
/static/dummy:
--------------------------------------------------------------------------------
1 | This file is needed to keep webpack copy plugin happy.
2 |
--------------------------------------------------------------------------------
/firestore.indexes.json:
--------------------------------------------------------------------------------
1 | {
2 | "indexes": [],
3 | "fieldOverrides": []
4 | }
5 |
--------------------------------------------------------------------------------
/assets/campus.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/loomchild/sojourner-web/HEAD/assets/campus.png
--------------------------------------------------------------------------------
/assets/building-aw.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/loomchild/sojourner-web/HEAD/assets/building-aw.png
--------------------------------------------------------------------------------
/assets/building-h.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/loomchild/sojourner-web/HEAD/assets/building-h.png
--------------------------------------------------------------------------------
/assets/building-j.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/loomchild/sojourner-web/HEAD/assets/building-j.png
--------------------------------------------------------------------------------
/assets/building-k.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/loomchild/sojourner-web/HEAD/assets/building-k.png
--------------------------------------------------------------------------------
/assets/building-u.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/loomchild/sojourner-web/HEAD/assets/building-u.png
--------------------------------------------------------------------------------
/assets/dashboard.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/loomchild/sojourner-web/HEAD/assets/dashboard.jpg
--------------------------------------------------------------------------------
/assets/menu-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/loomchild/sojourner-web/HEAD/assets/menu-logo.png
--------------------------------------------------------------------------------
/assets/background-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/loomchild/sojourner-web/HEAD/assets/background-1.png
--------------------------------------------------------------------------------
/assets/background-2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/loomchild/sojourner-web/HEAD/assets/background-2.png
--------------------------------------------------------------------------------
/assets/background-3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/loomchild/sojourner-web/HEAD/assets/background-3.png
--------------------------------------------------------------------------------
/assets/background-4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/loomchild/sojourner-web/HEAD/assets/background-4.png
--------------------------------------------------------------------------------
/assets/background-5.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/loomchild/sojourner-web/HEAD/assets/background-5.png
--------------------------------------------------------------------------------
/assets/logo-toolbar.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/loomchild/sojourner-web/HEAD/assets/logo-toolbar.png
--------------------------------------------------------------------------------
/assets/logo-small-inner.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/loomchild/sojourner-web/HEAD/assets/logo-small-inner.png
--------------------------------------------------------------------------------
/assets/logo-small-outer.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/loomchild/sojourner-web/HEAD/assets/logo-small-outer.png
--------------------------------------------------------------------------------
/assets/dashboard-desktop.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/loomchild/sojourner-web/HEAD/assets/dashboard-desktop.png
--------------------------------------------------------------------------------
/assets/sojourner-icon-112.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/loomchild/sojourner-web/HEAD/assets/sojourner-icon-112.png
--------------------------------------------------------------------------------
/assets/sojourner-icon-192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/loomchild/sojourner-web/HEAD/assets/sojourner-icon-192.png
--------------------------------------------------------------------------------
/assets/sojourner-icon-224.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/loomchild/sojourner-web/HEAD/assets/sojourner-icon-224.png
--------------------------------------------------------------------------------
/assets/sojourner-icon-512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/loomchild/sojourner-web/HEAD/assets/sojourner-icon-512.png
--------------------------------------------------------------------------------
/assets/sojourner-icon-56.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/loomchild/sojourner-web/HEAD/assets/sojourner-icon-56.png
--------------------------------------------------------------------------------
/static/screenshots/events.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/loomchild/sojourner-web/HEAD/static/screenshots/events.png
--------------------------------------------------------------------------------
/static/screenshots/search.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/loomchild/sojourner-web/HEAD/static/screenshots/search.png
--------------------------------------------------------------------------------
/assets/sojourner-transparent.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/loomchild/sojourner-web/HEAD/assets/sojourner-transparent.png
--------------------------------------------------------------------------------
/static/screenshots/dashboard.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/loomchild/sojourner-web/HEAD/static/screenshots/dashboard.png
--------------------------------------------------------------------------------
/static/screenshots/devrooms.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/loomchild/sojourner-web/HEAD/static/screenshots/devrooms.png
--------------------------------------------------------------------------------
/static/sojourner-preview-2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/loomchild/sojourner-web/HEAD/static/sojourner-preview-2.png
--------------------------------------------------------------------------------
/static/sojourner-preview-3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/loomchild/sojourner-web/HEAD/static/sojourner-preview-3.png
--------------------------------------------------------------------------------
/static/sojourner-preview-s.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/loomchild/sojourner-web/HEAD/static/sojourner-preview-s.jpg
--------------------------------------------------------------------------------
/.firebaserc:
--------------------------------------------------------------------------------
1 | {
2 | "projects": {
3 | "sojourner-web": "sojourer-web",
4 | "default": "sojourer-web"
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/assets/sojourner-icon-app-112.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/loomchild/sojourner-web/HEAD/assets/sojourner-icon-app-112.png
--------------------------------------------------------------------------------
/assets/sojourner-icon-app-180.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/loomchild/sojourner-web/HEAD/assets/sojourner-icon-app-180.png
--------------------------------------------------------------------------------
/assets/sojourner-icon-app-192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/loomchild/sojourner-web/HEAD/assets/sojourner-icon-app-192.png
--------------------------------------------------------------------------------
/assets/sojourner-icon-app-224.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/loomchild/sojourner-web/HEAD/assets/sojourner-icon-app-224.png
--------------------------------------------------------------------------------
/assets/sojourner-icon-app-512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/loomchild/sojourner-web/HEAD/assets/sojourner-icon-app-512.png
--------------------------------------------------------------------------------
/functions/cors.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "origin": ["*"],
4 | "method": ["GET"],
5 | "maxAgeSeconds": 3600
6 | }
7 | ]
8 |
--------------------------------------------------------------------------------
/logic/Link.js:
--------------------------------------------------------------------------------
1 | export default class Link {
2 | constructor (data) {
3 | this.href = data.href
4 | this.title = data.title
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/logic/Video.js:
--------------------------------------------------------------------------------
1 | export default class Video {
2 | constructor (data = {}) {
3 | this.type = data.type
4 | this.url = data.url
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/pages/Edition.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
7 |
8 |
10 |
--------------------------------------------------------------------------------
/logic/Room.js:
--------------------------------------------------------------------------------
1 | export default class Room {
2 | constructor (data = {}) {
3 | this.name = data.name
4 | this.building = data.building || {}
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/functions/logic/Link.js:
--------------------------------------------------------------------------------
1 | module.exports = class Link {
2 | constructor (data) {
3 | this.href = data.href
4 | this.title = data.title
5 | Object.freeze(this)
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/functions/logic/Video.js:
--------------------------------------------------------------------------------
1 | module.exports = class Video {
2 | constructor (data) {
3 | this.type = data.type
4 | this.url = data.url
5 | Object.freeze(this)
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/logic/Track.js:
--------------------------------------------------------------------------------
1 | import Type from './Type'
2 |
3 | export default class Track {
4 | constructor (data) {
5 | this.name = data.name
6 | this.type = data.type || new Type()
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/functions/sample.env:
--------------------------------------------------------------------------------
1 | FOSDEM_SCHEDULE_URL=https://fosdem.org/2020/schedule/xml
2 | FLOWCON_SCHEDULE_URL=
3 | FLOWCON_SCHEDULE_KEY=
4 | GOOGLE_APPLICATION_CREDENTIALS=[path-to-service-account.json]
5 |
--------------------------------------------------------------------------------
/firestore.rules:
--------------------------------------------------------------------------------
1 | service cloud.firestore {
2 | match /databases/{database}/documents {
3 | match /users/{userId} {
4 | allow read, write: if request.auth.uid == userId;
5 | }
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/functions/admin/common.js:
--------------------------------------------------------------------------------
1 | const { createHash } = require('crypto')
2 |
3 | function hashUid (uid) {
4 | return createHash('sha256').update(uid).digest('hex')
5 | }
6 |
7 | module.exports = { hashUid }
8 |
--------------------------------------------------------------------------------
/init/initStyle.js:
--------------------------------------------------------------------------------
1 | import '@mdi/font/css/materialdesignicons.css'
2 | import 'typeface-rubik'
3 | import 'typeface-roboto-condensed'
4 |
5 | import '@/preload.css'
6 |
7 | export default function () {}
8 |
--------------------------------------------------------------------------------
/functions/logic/Type.js:
--------------------------------------------------------------------------------
1 | module.exports = class Type {
2 | constructor (data) {
3 | this.id = data.id
4 | this.name = data.name
5 | this.statName = data.statName
6 | Object.freeze(this)
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/logic/Building.js:
--------------------------------------------------------------------------------
1 | export default class Building {
2 | constructor (data) {
3 | this.name = data.name
4 | }
5 |
6 | containsRoom (roomName) {
7 | return roomName.toUpperCase().startsWith(this.name)
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/functions/auth.js:
--------------------------------------------------------------------------------
1 | const functions = require('firebase-functions')
2 |
3 | function isAdmin (req) {
4 | const token = (req.headers.authorization || '').split(' ')[1]
5 |
6 | if (!token || token !== functions.config().admin.token) {
7 | return false
8 | }
9 |
10 | return true
11 | }
12 |
13 | module.exports = { isAdmin }
14 |
--------------------------------------------------------------------------------
/functions/indexEvents.js:
--------------------------------------------------------------------------------
1 | module.exports = function indexEvents (events) {
2 | const index = {}
3 | for (const event of events) {
4 | const blob = JSON.stringify(event, null, 2).toLowerCase()
5 | .replace(/"[a-zA-Z0-9_]+":|/g, '').replace(/",|"|/g, '')
6 | index[event.id] = blob
7 | }
8 |
9 | return index
10 | }
11 |
--------------------------------------------------------------------------------
/storage.rules:
--------------------------------------------------------------------------------
1 | rules_version = '2';
2 |
3 | // Craft rules based on data in your Firestore database
4 | // allow write: if firestore.get(
5 | // /databases/(default)/documents/users/$(request.auth.uid)).data.isAdmin;
6 | service firebase.storage {
7 | match /b/{bucket}/o {
8 | match /{allPaths=**} {
9 | allow read, write: if false;
10 | }
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/logic/Type.js:
--------------------------------------------------------------------------------
1 | export default class Type {
2 | constructor (data = {}) {
3 | this.id = data.id
4 | this.priority = data.priority
5 | this.name = data.name
6 | this.statName = data.statName
7 | this.background = data.background
8 | this.mobileColor = data.mobileColor
9 | this.desktopColor = data.desktopColor
10 | this.arrowColor = data.arrowColor
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/main.js:
--------------------------------------------------------------------------------
1 | import Vue from 'vue'
2 |
3 | import store from './store'
4 | import vuetify from './vuetify'
5 | import init from './init'
6 | import { router } from './pages'
7 | import App from '@/layout/App'
8 |
9 | init()
10 |
11 | /* eslint-disable no-new */
12 | new Vue({
13 | el: '#app',
14 | router,
15 | store,
16 | vuetify,
17 | components: {
18 | app: App
19 | },
20 | template: ' '
21 | })
22 |
--------------------------------------------------------------------------------
/init/index.js:
--------------------------------------------------------------------------------
1 | import initServiceWorker from './initServiceWorker'
2 | import initSentry from './initSentry'
3 | import initFixes from './initFixes'
4 | import initFirebase from './initFirebase'
5 | import initStyle from './initStyle'
6 |
7 | export default function () {
8 | // asynchronous init, not waiting for operations, no order
9 | initServiceWorker()
10 | initSentry()
11 | initFixes()
12 | initFirebase()
13 | initStyle()
14 | }
15 |
--------------------------------------------------------------------------------
/logic/Day.js:
--------------------------------------------------------------------------------
1 | import moment from 'moment'
2 | const WEEKDAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
3 | const DATE_FORMAT = 'YYYY-MM-DD'
4 |
5 | export default class Day {
6 | constructor (data) {
7 | this.date = moment(data.date).toDate()
8 | this.dateString = moment(this.date).format(DATE_FORMAT)
9 | this.index = (this.date.getDay() + 6) % 7
10 | this.name = WEEKDAYS[this.index]
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/sample.env:
--------------------------------------------------------------------------------
1 | ROOM_STATE_URL=https://fosdem.loomchild.net/listrooms
2 | ROOM_STATE_INTERVAL=60
3 |
4 | ANALYTICS_URL=
5 |
6 | SENTRY_DSN=
7 |
8 | FIREBASE_API_KEY=AIzaSyDdGLMo8SACp-7E2g_HXaPs-0VWjBktRpA
9 | FIREBASE_AUTH_DOMAIN=sojourer-web.firebaseapp.com
10 | FIREBASE_DATABASE_URL=https://sojourer-web.firebaseio.com
11 | FIREBASE_PROJECT_ID=sojourer-web
12 | FIREBASE_STORAGE_BUCKET=sojourer-web.appspot.com
13 | FIREBASE_MESSAGING_SENDER_ID=618264640987
14 |
--------------------------------------------------------------------------------
/components/Analytics.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
14 |
15 |
23 |
--------------------------------------------------------------------------------
/static/.well-known/assetlinks.json:
--------------------------------------------------------------------------------
1 | [{
2 | "relation": ["delegate_permission/common.handle_all_urls"],
3 | "target": {
4 | "namespace": "android_app",
5 | "package_name": "rocks.sojourner.fosdem.twa",
6 | "sha256_cert_fingerprints": ["F9:D4:8D:27:8B:2E:D0:8A:A1:79:42:8B:8C:97:78:21:77:7F:01:00:75:F8:A0:6E:E1:F8:A7:DC:6C:AD:E8:D4", "1F:A2:B5:B0:F9:4B:8E:63:F8:4A:C3:AB:7D:6B:63:82:93:78:72:3A:A7:BC:BB:DE:46:5A:75:9A:81:D9:1C:BD"]
7 | }
8 | }]
9 |
--------------------------------------------------------------------------------
/vuetify.js:
--------------------------------------------------------------------------------
1 | import Vue from 'vue'
2 | import Vuetify from 'vuetify'
3 | import 'vuetify/dist/vuetify.min.css'
4 | import config from './config'
5 |
6 | Vue.use(Vuetify)
7 |
8 | export default new Vuetify({
9 | treeShake: true,
10 | theme: {
11 | themes: {
12 | light: {
13 | ...config.colors
14 | },
15 | dark: {
16 | ...config.colors
17 | }
18 | },
19 | options: {
20 | customProperties: true
21 | },
22 | icons: {
23 | iconfont: 'mdi'
24 | }
25 | }
26 | })
27 |
--------------------------------------------------------------------------------
/functions/migrate/001-migrateFavouritesToConference.js:
--------------------------------------------------------------------------------
1 | const admin = require('firebase-admin')
2 |
3 | module.exports = async function () {
4 | const snapshot = await admin.firestore().collection('users').get()
5 | for (const doc of snapshot.docs) {
6 | const user = doc.data()
7 | if (user.favourites) {
8 | const favouriteStrings = user.favourites.map(f => f.toString())
9 | await doc.ref.update({
10 | 'fosdem-2019': { favourites: favouriteStrings },
11 | favourites: admin.firestore.FieldValue.delete()
12 | })
13 | }
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/init/initSentry.js:
--------------------------------------------------------------------------------
1 | import Vue from 'vue'
2 | import * as Sentry from '@sentry/browser'
3 | import * as Integrations from '@sentry/integrations'
4 |
5 | export default async function () {
6 | if (process.env.SENTRY_DSN) {
7 | // Workaround for https://github.com/vuejs/vue/issues/8433
8 | Vue.config.errorHandler = (err, vm, info) => {
9 | console.error(err)
10 | }
11 |
12 | Sentry.init({
13 | dsn: process.env.SENTRY_DSN,
14 | integrations: [new Integrations.Vue({ Vue })]
15 | })
16 |
17 | console.log('Sentry initialized')
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/components/Chat.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | mdi-forum
5 |
6 |
7 |
8 |
9 |
21 |
22 |
32 |
--------------------------------------------------------------------------------
/functions/admin/users.js:
--------------------------------------------------------------------------------
1 | const admin = require('firebase-admin')
2 | const { hashUid } = require('./common')
3 |
4 | async function listUsers (users = {}, token = undefined) {
5 | const result = await admin.auth().listUsers(1000, token)
6 | result.users.forEach(user => {
7 | users[hashUid(user.uid)] = {
8 | createdAt: new Date(user.metadata.creationTime).toISOString(),
9 | isRegistered: !!user.email
10 | }
11 | })
12 |
13 | if (result.pageToken) {
14 | await listUsers(users, result.pageToken)
15 | }
16 |
17 | return users
18 | }
19 |
20 | module.exports = async function () {
21 | return listUsers()
22 | }
23 |
--------------------------------------------------------------------------------
/functions/logic/Event.js:
--------------------------------------------------------------------------------
1 | module.exports = class Event {
2 | constructor (data) {
3 | this.id = data.id
4 | this.date = data.date
5 | this.startTime = data.startTime
6 | this.duration = data.duration
7 | this.title = data.title
8 | this.subtitle = data.subtitle
9 | this.abstract = data.abstract
10 | this.description = data.description
11 | this.type = data.type
12 | this.track = data.track
13 | this.room = data.room
14 | this.persons = data.persons
15 | this.links = data.links
16 | this.videos = data.videos
17 | this.language = data.language
18 | this.chat = data.chat
19 | Object.freeze(this)
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/test/store/schedule.test.js:
--------------------------------------------------------------------------------
1 | import sinon from 'sinon'
2 |
3 | import store from '@/store'
4 | import json from '@/test/resources/fosdem-2019.json'
5 |
6 | describe('data/getSchedule', function () {
7 | this.timeout(10000)
8 |
9 | let stubbedFetch
10 |
11 | beforeEach(() => {
12 | stubbedFetch = sinon.stub(window, 'fetch')
13 | fetch.resolves(new Response(JSON.stringify(json), { status: 200 }))
14 | })
15 |
16 | afterEach(() => {
17 | stubbedFetch.restore()
18 | })
19 |
20 | it('should retrieve all events', async () => {
21 | await store.dispatch('initSchedule')
22 |
23 | expect(store.state.schedule.events['8967']).to.not.be.undefined
24 | })
25 | })
26 |
--------------------------------------------------------------------------------
/store/map.js:
--------------------------------------------------------------------------------
1 | import Building from '@/logic/Building'
2 |
3 | import config from '@/config'
4 |
5 | export default {
6 | state: {
7 | buildings: {
8 | J: new Building({ name: 'J' }),
9 | H: new Building({ name: 'H' }),
10 | AW: new Building({ name: 'AW' }),
11 | U: new Building({ name: 'U' }),
12 | K: new Building({ name: 'K' })
13 | }
14 | },
15 |
16 | getters: {
17 | hasMap: (state, getters, rootState, rootGetters) => config.features.map && rootGetters.isLatestConferenceEdition,
18 |
19 | buildings: state => state.buildings,
20 |
21 | roomBuilding: state => roomName => Object.values(state.buildings).filter(building => building.containsRoom(roomName))[0]
22 | }
23 |
24 | }
25 |
--------------------------------------------------------------------------------
/logic/RoomState.js:
--------------------------------------------------------------------------------
1 | export default class RoomState {
2 | constructor (data) {
3 | this.room = data.room
4 | this.state = data.state !== undefined ? data.state : -1
5 | this.emergency = false
6 |
7 | if (this.state === -1) {
8 | this.name = 'Unknown room state'
9 | } else if (this.state === 0) {
10 | this.name = 'Room open'
11 | } else if (this.state === 1) {
12 | this.name = 'Room full'
13 | this.icon = 'mdi-minus-circle'
14 | } else if (this.state === 2) {
15 | this.name = 'Room emergency evacuation'
16 | this.icon = 'mdi-minus-circle'
17 | this.emergency = true
18 | } else {
19 | throw new Error(`Unknown room state: ${this.state}`)
20 | }
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/store/index.js:
--------------------------------------------------------------------------------
1 | import Vue from 'vue'
2 | import Vuex from 'vuex'
3 |
4 | import conference from './conference'
5 | import meta from './meta'
6 | import notification from './notification'
7 | import favourite from './favourite'
8 | import map from './map'
9 | import schedule from './schedule'
10 | import state from './state'
11 | import user from './user'
12 | import player from './player'
13 | import cron from './cron'
14 | import history from './history'
15 |
16 | Vue.use(Vuex)
17 |
18 | export default new Vuex.Store({
19 | modules: {
20 | meta,
21 | notification,
22 | map,
23 | favourite,
24 | conference,
25 | schedule,
26 | state,
27 | user,
28 | player,
29 | cron,
30 | history
31 | }
32 | })
33 |
--------------------------------------------------------------------------------
/components/RoomState.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | {{ state.icon }}
4 |
5 |
6 |
7 |
31 |
32 |
39 |
--------------------------------------------------------------------------------
/functions/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "sojourner-functions",
3 | "scripts": {
4 | "dev": "firebase serve --only functions",
5 | "shell": "firebase functions:shell",
6 | "start": "npm run shell",
7 | "deploy": "NODE_ENV=production firebase deploy --only functions",
8 | "config": "firebase functions:config:get",
9 | "logs": "firebase functions:log"
10 | },
11 | "engines": {
12 | "node": "20"
13 | },
14 | "dependencies": {
15 | "axios": "^0.21.4",
16 | "dotenv": "^8.6.0",
17 | "firebase-admin": "^9.12.0",
18 | "firebase-functions": "^3.24.1",
19 | "moment": "^2.29.4",
20 | "xmldom": "^0.1.31",
21 | "xmltojson": "^1.3.5"
22 | },
23 | "devDependencies": {
24 | "firebase-functions-test": "^0.1.7"
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/firebase.json:
--------------------------------------------------------------------------------
1 | {
2 | "firestore": {
3 | "rules": "firestore.rules",
4 | "indexes": "y"
5 | },
6 | "functions": [
7 | {
8 | "source": "functions",
9 | "codebase": "default",
10 | "ignore": [
11 | "node_modules",
12 | ".git",
13 | "firebase-debug.log",
14 | "firebase-debug.*.log"
15 | ]
16 | }
17 | ],
18 | "storage": {
19 | "rules": "storage.rules"
20 | },
21 | "emulators": {
22 | "auth": {
23 | "port": 9099
24 | },
25 | "functions": {
26 | "port": 5001
27 | },
28 | "firestore": {
29 | "port": 8080
30 | },
31 | "storage": {
32 | "port": 9199
33 | },
34 | "ui": {
35 | "enabled": false
36 | },
37 | "singleProjectMode": true
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/karma.conf.js:
--------------------------------------------------------------------------------
1 | process.env.CHROME_BIN = require('puppeteer').executablePath()
2 |
3 | const webpackConfig = require('./webpack.config')
4 |
5 | module.exports = function (config) {
6 | config.set({
7 | browsers: ['ChromeHeadlessNoSandbox'],
8 | customLaunchers: {
9 | ChromeHeadlessNoSandbox: {
10 | base: 'ChromeHeadless',
11 | flags: ['--no-sandbox']
12 | }
13 | },
14 | frameworks: ['mocha', 'chai-string', 'chai-datetime', 'sinon-chai'],
15 | reporters: ['spec'],
16 | files: [
17 | { pattern: 'test/**/*.test.js', watched: false }
18 | ],
19 | preprocessors: {
20 | 'test/**/*.test.js': ['webpack', 'sourcemap']
21 | },
22 | webpack: webpackConfig,
23 | webpackMiddleware: {
24 | noInfo: true
25 | }
26 | })
27 | }
28 |
--------------------------------------------------------------------------------
/pages/AllEvents.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
36 |
37 |
39 |
--------------------------------------------------------------------------------
/test/layout/App.test.js:
--------------------------------------------------------------------------------
1 | import Vue from 'vue'
2 | import Vuex from 'vuex'
3 | import vuetify from '../../vuetify'
4 | import VueRouter from 'vue-router'
5 | import { createLocalVue, shallow } from 'vue-test-utils'
6 |
7 | import App from '@/layout/App'
8 |
9 | Vue.config.productionTip = false
10 | Vue.use(Vuex)
11 | Vue.use(VueRouter)
12 |
13 | describe('App', () => {
14 | let wrapper
15 |
16 | beforeEach(() => {
17 | const localVue = createLocalVue()
18 | const store = new Vuex.Store({})
19 | const router = new VueRouter()
20 | wrapper = shallow(App, {
21 | localVue,
22 | store,
23 | router,
24 | vuetify,
25 | stubs: ['router-view']
26 | })
27 | })
28 |
29 | it('should have app', () => {
30 | expect(wrapper.find('#app').exists()).to.be.true
31 | })
32 | })
33 |
--------------------------------------------------------------------------------
/store/history.js:
--------------------------------------------------------------------------------
1 | import { router } from '@/pages'
2 |
3 | function getHistoryStateKey () {
4 | return window.history.state && window.history.state.key
5 | }
6 |
7 | export default {
8 | state: {
9 | initialHistoryStateKey: null
10 | },
11 |
12 | mutations: {
13 | initHistoryStateKey (state, historyStateKey) {
14 | state.initialHistoryStateKey = historyStateKey
15 | }
16 | },
17 |
18 | actions: {
19 | initHistory ({ commit }) {
20 | const historyStateKey = getHistoryStateKey()
21 | commit('initHistoryStateKey', historyStateKey)
22 | },
23 |
24 | goBack ({ state }) {
25 | if (state.initialHistoryStateKey && state.initialHistoryStateKey === getHistoryStateKey()) {
26 | router.push({ name: 'dashboard' })
27 | return
28 | }
29 |
30 | router.go(-1)
31 | }
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/init/initFirebase.js:
--------------------------------------------------------------------------------
1 | import { initializeApp, getApp } from 'firebase/app'
2 | import { initializeFirestore, persistentLocalCache, persistentMultipleTabManager } from 'firebase/firestore'
3 |
4 | export default async function () {
5 | try {
6 | const config = {
7 | apiKey: process.env.FIREBASE_API_KEY,
8 | authDomain: process.env.FIREBASE_AUTH_DOMAIN,
9 | databaseURL: process.env.FIREBASE_DATABASE_URL,
10 | projectId: process.env.FIREBASE_PROJECT_ID,
11 | storageBucket: process.env.FIREBASE_STORAGE_BUCKET,
12 | messagingSenderId: process.env.FIREBASE_MESSAGING_SENDER_ID
13 | }
14 |
15 | initializeApp(config)
16 |
17 | initializeFirestore(getApp(), {
18 | localCache: persistentLocalCache({ tabManager: persistentMultipleTabManager() })
19 | })
20 | } catch (error) {
21 | console.error(error)
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/components/MenuItem.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {{ icon }}
5 |
6 |
7 | {{ title }}
8 |
9 |
10 |
11 |
12 |
36 |
37 |
39 |
--------------------------------------------------------------------------------
/components/Play.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | mdi-play-circle-outline
4 |
5 |
6 |
7 |
41 |
42 |
48 |
--------------------------------------------------------------------------------
/functions/admin/favourites.js:
--------------------------------------------------------------------------------
1 | const admin = require('firebase-admin')
2 | const { hashUid } = require('./common')
3 |
4 | module.exports = async function (conference) {
5 | const snapshot = await admin.firestore().collection('users').where(`${conference}.favourites`, '!=', []).get()
6 |
7 | const users = {}
8 |
9 | for (const doc of snapshot.docs) {
10 | const user = doc.data()
11 |
12 | const favourites = {}
13 |
14 | for (const favourite of user[conference].favourites) {
15 | favourites[favourite] = {}
16 |
17 | if (user[conference].favouriteUpdates) {
18 | const updatedAt = user[conference].favouriteUpdates[favourite]
19 | if (updatedAt) {
20 | favourites[favourite].updatedAt = updatedAt.toDate().toISOString()
21 | }
22 | }
23 | }
24 |
25 | users[hashUid(doc.id)] = {
26 | favourites
27 | }
28 | }
29 |
30 | return users
31 | }
32 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 |
4 | env: {
5 | browser: true,
6 | mocha: true
7 | },
8 |
9 | parserOptions: {
10 | parser: 'babel-eslint'
11 | },
12 |
13 | extends: [
14 | 'standard',
15 | 'plugin:vue/recommended'
16 | ],
17 |
18 | plugins: [
19 | 'vue',
20 | 'vuetify',
21 | 'mocha',
22 | 'chai-friendly'
23 | ],
24 |
25 | globals: {
26 | expect: true,
27 | should: true
28 | },
29 |
30 | rules: {
31 | 'no-new': 0,
32 | 'no-unused-expressions': 0,
33 | 'chai-friendly/no-unused-expressions': 2,
34 | 'vue/html-self-closing': 0,
35 | 'vue/no-v-html': 0,
36 | "vue/max-attributes-per-line": [
37 | "error", {
38 | "singleline": 16
39 | }
40 | ],
41 | 'vuetify/no-deprecated-classes': 'error',
42 | 'vuetify/grid-unknown-attributes': 'error',
43 | 'vuetify/no-legacy-grid': 'error'
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/pages/LiveEvents.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
42 |
43 |
45 |
--------------------------------------------------------------------------------
/functions/store.js:
--------------------------------------------------------------------------------
1 | const admin = require('firebase-admin')
2 |
3 | module.exports = async function store (data, fileName) {
4 | const bucket = admin.storage().bucket()
5 |
6 | const file = bucket.file(`conferences/${fileName}`)
7 | const stringData = JSON.stringify(data, null, 2)
8 |
9 | const exists = (await file.exists())[0]
10 | if (exists) {
11 | const oldStringData = (await file.download())[0]
12 | try {
13 | const oldData = JSON.stringify(JSON.parse(oldStringData), null, 2)
14 | if (oldData === stringData) {
15 | console.log('Data unchanged, skipping')
16 | return
17 | }
18 | } catch (error) {
19 | console.log(`Error parsing old file: ${error.message}`)
20 | }
21 | }
22 |
23 | console.log('Storing conference data')
24 | await file.save(stringData)
25 | await file.makePublic()
26 | await file.setMetadata({ contentType: 'application/json', cacheControl: 'public, max-age=0, must-revalidate' })
27 | }
28 |
--------------------------------------------------------------------------------
/components/FavouriteTrack.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | mdi-star
4 | mdi-star-outline
5 |
6 |
7 |
8 |
38 |
39 |
48 |
--------------------------------------------------------------------------------
/components/TrackListPlain.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | There are no tracks on this list.
13 |
14 |
15 |
16 |
17 |
18 |
19 |
37 |
38 |
43 |
--------------------------------------------------------------------------------
/preload.css:
--------------------------------------------------------------------------------
1 | body {
2 | background-color: #ffffff;
3 | color: rgba(0, 0, 0, 0.87);
4 | }
5 |
6 | html {
7 | overscroll-behavior: none;
8 | }
9 |
10 | .middle {
11 | position: absolute;
12 | width: 150px;
13 | height: 150px;
14 | top: 50%;
15 | left: 50%;
16 | transform: translate(-50%,-50%);
17 | }
18 |
19 | @media screen and (display-mode: browser) {
20 | .middle .spinner {
21 | animation: scale-in 300ms ease-in both;
22 | }
23 | }
24 |
25 | /* Spinner */
26 |
27 | @keyframes spin {
28 | to {
29 | transform: rotate(360deg);
30 | }
31 | }
32 |
33 | @keyframes scale-in {
34 | 0% {
35 | transform: scale(0.5);
36 | }
37 | 100% {
38 | transform: scale(1);
39 | }
40 | }
41 |
42 | .spinner {
43 | width: 150px;
44 | height: 150px;
45 | background-image: url(~assets/logo-small-inner.png);
46 | }
47 |
48 | .spinner::before {
49 | content: '';
50 | position: absolute;
51 | width: 150px;
52 | height: 150px;
53 | animation: spin 4000ms linear 300ms infinite;
54 | background-image: url(~assets/logo-small-outer.png);
55 | }
56 |
--------------------------------------------------------------------------------
/components/Favourite.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | mdi-bookmark
4 | mdi-bookmark-outline
5 |
6 |
7 |
8 |
39 |
40 |
54 |
--------------------------------------------------------------------------------
/pages/ConferenceTrackEvents.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
52 |
53 |
55 |
--------------------------------------------------------------------------------
/pages/Delete.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | To delete your account and all associated favourites, please send an email to
9 | hello@jareklipski.com and you will receive further instructions.
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
34 |
35 |
54 |
--------------------------------------------------------------------------------
/pages/FavouriteEvents.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
59 |
60 |
62 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## 2025
4 |
5 | - Improve search: remember input, prioritize favourites, sticky.
6 | - Schedule: generate and host from backoffice, use stable event GUID, refresh immediately.
7 |
8 | ## 2024
9 |
10 | - Handle all editions in one app (to avoid reinstallation every year).
11 | - Introduce [Sojourner back-office app](https://bo.sojourner.rocks).
12 | - Favourite tracks.
13 | - Share bookmarks.
14 | - Ignore accents in search.
15 | - Improve login, logout and registration errors.
16 |
17 | ## 2023
18 |
19 | - No major changes.
20 |
21 | ## 2022
22 |
23 | - No major changes.
24 |
25 | ## 2021
26 |
27 | - Support streaming video player in preparation for fully-remote conference during Covid-19 pandemic.
28 |
29 | ## 2020
30 |
31 | - Change design completely with help from UX/UI designer.
32 | - Offline-first PWA, no need for internet connection.
33 | - Present a [talk about PWAs and Sojourner](https://fosdem.sojourner.rocks/2020/event/10109) in JavaScript FOSDEM devroom.
34 |
35 | ## 2019
36 |
37 | - Store bookmarks in Firebase, share between devices.
38 |
39 | ## 2018
40 |
41 | - Rewrite original [Sojourner N900 app](https://github.com/loomchild/sojourner) as a PWA, using Vue.js and Vuetify.
42 | - All existing functionality suppored, plus dynamic room state.
43 |
--------------------------------------------------------------------------------
/store/notification.js:
--------------------------------------------------------------------------------
1 | const DEFAULT_NOTIFICATION_TIMEOUT = 10000
2 |
3 | export default {
4 | state: {
5 | notifications: []
6 | },
7 |
8 | mutations: {
9 | pushNotification (state, notification) {
10 | if (!state.notifications.some(n => n.message === notification.message)) {
11 | state.notifications.push(notification)
12 | }
13 | },
14 |
15 | popNotification (state) {
16 | state.notifications.shift()
17 | }
18 | },
19 |
20 | getters: {
21 | notifications: state => state.notifications
22 | },
23 |
24 | actions: {
25 | hideNotification ({ commit }) {
26 | commit('popNotification')
27 | },
28 |
29 | showNotification ({ commit }, notification) {
30 | if (notification.timeout === undefined) {
31 | notification.timeout = DEFAULT_NOTIFICATION_TIMEOUT
32 | }
33 | commit('pushNotification', notification)
34 | },
35 |
36 | showMessage ({ dispatch }, message) {
37 | dispatch('showNotification', { message })
38 | },
39 |
40 | showWarning ({ dispatch }, message) {
41 | dispatch('showNotification', { message, level: 'warning' })
42 | },
43 |
44 | showError ({ dispatch }, message) {
45 | dispatch('showNotification', { message, level: 'error' })
46 | }
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/components/EventListPlain.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | There are no events on this list.
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
44 |
45 |
50 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Sojourner Web
2 |
3 | [](https://app.netlify.com/sites/fosdem-sojourner/deploys)
4 |
5 | > Sojourner conference companion PWA
6 |
7 | # Requirements
8 |
9 | * Node 20.x
10 |
11 | # Build
12 |
13 | Download node dependencies:
14 |
15 | npm install
16 |
17 | # Run
18 |
19 | Start the local development server:
20 |
21 | npm run dev
22 |
23 | # Test
24 |
25 | Launch style check:
26 |
27 | npm run lint
28 |
29 | Launch unit / integration tests:
30 |
31 | npm run test
32 |
33 | # Deploy
34 |
35 | Static application can be generated into dist/ directory using:
36 |
37 | npm run build
38 |
39 | You can test it locally by running:
40 |
41 | npm run start
42 |
43 | The deployment happens automatically to Netlify, nothing specific to do.
44 |
45 | *Note: use annotated tags to make them visible on About page.*
46 |
47 | # Miscellaneous
48 |
49 | ## Handling images
50 |
51 | Resize PNGs using Tiny PNG: https://tinypng.com/
52 | This could be done with GIMP, but unfortunately it doesn't preserve transparency when converting PNGs to indexed mode.
53 |
54 | # Schedule
55 |
56 | ## CORS
57 |
58 | To configure storage to allow CORS, see [cors configuration](https://firebase.google.com/docs/storage/web/download-files#cors_configuration) in documentation.
59 |
--------------------------------------------------------------------------------
/pages/SharedEvents.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
58 |
59 |
61 |
--------------------------------------------------------------------------------
/store/cron.js:
--------------------------------------------------------------------------------
1 | import moment from 'moment'
2 |
3 | const TEST = false
4 |
5 | const CRON_UPDATE_INTERVAL = !TEST ? 60 * 1000 : 1000
6 | const TIME_FORMAT = 'HH:mm'
7 | const DATE_FORMAT = 'YYYY-MM-DD'
8 |
9 | function getDate (date) {
10 | return moment(date).format(DATE_FORMAT)
11 | }
12 |
13 | function getTime (date) {
14 | return moment(date).format(TIME_FORMAT)
15 | }
16 |
17 | export default {
18 | state: {
19 | currentDate: null,
20 | currentTime: null
21 | },
22 |
23 | getters: {
24 | currentDate: state => state.currentDate,
25 |
26 | currentTime: state => state.currentTime
27 | },
28 |
29 | mutations: {
30 | setCurrentDate (state, currentDate) {
31 | state.currentDate = currentDate
32 | },
33 |
34 | setCurrentTime (state, currentTime) {
35 | state.currentTime = currentTime
36 | }
37 | },
38 |
39 | actions: {
40 | initCron ({ dispatch }) {
41 | dispatch('updateCron')
42 | setInterval(() => dispatch('updateCron'), CRON_UPDATE_INTERVAL)
43 | },
44 |
45 | updateCron ({ commit, dispatch }) {
46 | const date = !TEST ? new Date() : moment().year(2025).month('February').date(1).hour(10).minute(30)
47 | const currentDate = getDate(date)
48 | const currentTime = getTime(date)
49 | commit('setCurrentDate', currentDate)
50 | commit('setCurrentTime', currentTime)
51 |
52 | dispatch('notifyTrackEvent')
53 | }
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/components/DayTabs.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
27 |
28 |
30 |
31 |
63 |
--------------------------------------------------------------------------------
/pages/TypeTracksOrEvents.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
64 |
65 |
67 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Created by https://www.gitignore.io/api/vim,node
2 |
3 | ### Node ###
4 | # Logs
5 | logs
6 | *.log
7 | npm-debug.log*
8 | yarn-debug.log*
9 | yarn-error.log*
10 |
11 | # Runtime data
12 | pids
13 | *.pid
14 | *.seed
15 | *.pid.lock
16 |
17 | # Directory for instrumented libs generated by jscoverage/JSCover
18 | lib-cov
19 |
20 | # Coverage directory used by tools like istanbul
21 | coverage
22 |
23 | # nyc test coverage
24 | .nyc_output
25 |
26 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
27 | .grunt
28 |
29 | # Bower dependency directory (https://bower.io/)
30 | bower_components
31 |
32 | # node-waf configuration
33 | .lock-wscript
34 |
35 | # Compiled binary addons (http://nodejs.org/api/addons.html)
36 | build/Release
37 |
38 | # Dependency directories
39 | node_modules/
40 | jspm_packages/
41 |
42 | # Typescript v1 declaration files
43 | typings/
44 |
45 | # Optional npm cache directory
46 | .npm
47 |
48 | # Optional eslint cache
49 | .eslintcache
50 |
51 | # Optional REPL history
52 | .node_repl_history
53 |
54 | # Output of 'npm pack'
55 | *.tgz
56 |
57 | # Yarn Integrity file
58 | .yarn-integrity
59 |
60 | # dotenv environment variables file
61 | .env*
62 |
63 | ### Vim ###
64 | # swap
65 | [._]*.s[a-v][a-z]
66 | [._]*.sw[a-p]
67 | [._]s[a-v][a-z]
68 | [._]sw[a-p]
69 | # session
70 | Session.vim
71 | # temporary
72 | .netrwhist
73 | *~
74 | # auto-generated tag files
75 | tags
76 |
77 | # End of https://www.gitignore.io/api/vim,node
78 |
79 | # Custom
80 | .tern-port
81 | tests_output/
82 | dist/
83 |
84 | # Firebase local config
85 | .runtimeconfig.json
86 |
--------------------------------------------------------------------------------
/pages/BuildingMap.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
53 |
54 |
76 |
--------------------------------------------------------------------------------
/netlify.toml:
--------------------------------------------------------------------------------
1 | [context.production.environment]
2 | FIREBASE_API_KEY = "AIzaSyDdGLMo8SACp-7E2g_HXaPs-0VWjBktRpA"
3 | FIREBASE_AUTH_DOMAIN = "sojourer-web.firebaseapp.com"
4 | FIREBASE_DATABASE_URL = "https://sojourer-web.firebaseio.com"
5 | FIREBASE_PROJECT_ID = "sojourer-web"
6 | FIREBASE_STORAGE_BUCKET = "sojourer-web.appspot.com"
7 | FIREBASE_MESSAGING_SENDER_ID = "618264640987"
8 |
9 | [[headers]]
10 | for = "/service-worker.js"
11 | [headers.values]
12 | cache-control = '''
13 | max-age=0,
14 | no-cache,
15 | no-store,
16 | must-revalidate'''
17 |
18 | [[redirects]]
19 | from = "https://fosdem-sojourner.netlify.app/*"
20 | to = "https://fosdem.sojourner.rocks/:splat"
21 | status = 301
22 | force = true
23 |
24 | [[redirects]]
25 | from = "https://2023.fosdem.sojourner.rocks/*"
26 | to = "https://fosdem.sojourner.rocks/2023/:splat"
27 | status = 302
28 | force = true
29 |
30 | [[redirects]]
31 | from = "https://2022.fosdem.sojourner.rocks/*"
32 | to = "https://fosdem.sojourner.rocks/2022/:splat"
33 | status = 302
34 | force = true
35 |
36 | [[redirects]]
37 | from = "https://2021.fosdem.sojourner.rocks/*"
38 | to = "https://fosdem.sojourner.rocks/2021/:splat"
39 | status = 302
40 | force = true
41 |
42 | [[redirects]]
43 | from = "https://2020.fosdem.sojourner.rocks/*"
44 | to = "https://fosdem.sojourner.rocks/2020/:splat"
45 | status = 302
46 | force = true
47 |
48 | [[redirects]]
49 | from = "https://2019.fosdem.sojourner.rocks/*"
50 | to = "https://fosdem.sojourner.rocks/2019/:splat"
51 | status = 302
52 | force = true
53 |
54 | [[redirects]]
55 | from = "/*"
56 | to = "/index.html"
57 | status = 200
58 |
--------------------------------------------------------------------------------
/logic/Event.js:
--------------------------------------------------------------------------------
1 | import moment from 'moment'
2 |
3 | import Room from './Room'
4 | import Type from './Type'
5 |
6 | const TIME_FORMAT = 'HH:mm'
7 |
8 | export default class Event {
9 | constructor (data = {}) {
10 | this.exists = !!data.id
11 | this.id = data.id
12 | this.startTime = data.startTime
13 | this.duration = data.duration
14 | this.title = data.title
15 | this.subtitle = data.subtitle
16 | this.abstract = data.abstract
17 | this.description = data.description
18 | this.language = data.language
19 |
20 | this.type = data.type || new Type()
21 | this.track = data.track || {}
22 | this.day = data.day || {}
23 | this.room = data.room || new Room()
24 | this.persons = data.persons || []
25 | this.links = data.links || []
26 | this.videos = data.videos || []
27 | this.chat = data.chat
28 |
29 | if (this.startTime && this.duration) {
30 | this.endTime = moment(this.startTime, TIME_FORMAT).add(moment.duration(this.duration)).format(TIME_FORMAT)
31 | }
32 | }
33 |
34 | get speakers () {
35 | return this.persons.join(', ')
36 | }
37 |
38 | happeningNow (currentDate, currentTime) {
39 | return this.day.dateString === currentDate &&
40 | this.startTime <= currentTime && this.endTime >= currentTime &&
41 | this.track.name !== 'Infodesk'
42 | }
43 |
44 | happeningLive (isFavourite, currentDate, currentTime, minEndTime, minStartTime, maxStartTime) {
45 | return this.day.dateString === currentDate &&
46 | this.startTime <= maxStartTime &&
47 | ((isFavourite && this.endTime >= currentTime) || (this.startTime >= minStartTime && this.endTime >= minEndTime)) &&
48 | this.track.name !== 'Infodesk'
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/init/initServiceWorker.js:
--------------------------------------------------------------------------------
1 | import { Workbox } from 'workbox-window'
2 |
3 | import store from '@/store'
4 |
5 | export default async function () {
6 | if ('serviceWorker' in navigator) {
7 | const wb = new Workbox('/service-worker.js', { updateViaCache: 'none' })
8 |
9 | wb.addEventListener('installed', event => {
10 | console.log('installed')
11 | })
12 |
13 | wb.addEventListener('activated', event => {
14 | console.log('activated')
15 |
16 | caches.delete(store.getters.conferenceScheduleUrl)
17 | caches.delete('https://fosdem.sojourner.rocks/assets/manifest.json')
18 |
19 | if (event.isUpdate) {
20 | console.log('update')
21 | store.dispatch('notifyNewVersion')
22 | } else {
23 | wb.messageSW({
24 | type: 'CACHE_URLS',
25 | payload: {
26 | urlsToCache: [store.getters.conferenceScheduleUrl]
27 | }
28 | })
29 | }
30 | })
31 |
32 | wb.addEventListener('controlling', event => {
33 | console.log('controlling')
34 | })
35 |
36 | wb.addEventListener('message', event => {
37 | console.log(`message ${event.data.type}`)
38 | if (event.data.type === 'CACHE_UPDATED') {
39 | store.dispatch('notifyRefreshSchedule')
40 | }
41 | })
42 |
43 | const registration = await wb.register()
44 |
45 | registration.addEventListener('updatefound', () => {
46 | const installingWorker = registration.installing
47 | installingWorker.addEventListener('statechange', () => {
48 | if (installingWorker.state === 'activated') {
49 | store.dispatch('notifyNewVersion')
50 | }
51 | })
52 | })
53 |
54 | setInterval(async () => {
55 | console.log('Checking for updates...')
56 | registration.update()
57 | }, 15 * 60 * 1000)
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/components/ConferenceTrack.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {{ track.track.name }}
5 |
6 | {{ track.events.length }} events
7 |
8 | |
9 |
10 | {{ room.name }}
11 |
12 | ,
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
53 |
54 |
69 |
--------------------------------------------------------------------------------
/components/BottomMenu.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | mdi-home
5 | Programme
6 |
7 |
8 |
9 | mdi-television-classic
10 | Live
11 |
12 |
13 |
14 | mdi-bookmark-multiple
15 | Bookmarks
16 |
17 |
18 |
19 | mdi-view-headline
20 | All
21 |
22 |
23 |
24 | mdi-map
25 | Map
26 |
27 |
28 |
29 | mdi-magnify
30 | Search
31 |
32 |
33 |
34 |
35 |
55 |
56 |
66 |
--------------------------------------------------------------------------------
/components/TrackList.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | {{ dayTracks.day.name }}
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
87 |
--------------------------------------------------------------------------------
/components/Notification.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | {{ notification.message }}
6 |
7 | {{ notification.button.title }}
8 |
9 |
10 |
11 |
12 | mdi-close
13 |
14 |
15 |
16 |
17 |
18 |
19 |
77 |
78 |
85 |
--------------------------------------------------------------------------------
/store/conference.js:
--------------------------------------------------------------------------------
1 | import config from '@/config'
2 | import { router } from '@/pages'
3 |
4 | export default {
5 | state: {
6 | conferenceEdition: null,
7 |
8 | conferenceId: null
9 | },
10 |
11 | getters: {
12 | conferenceEdition: (state, getters) => state.conferenceEdition,
13 |
14 | latestConferenceEdition: () => config.conference.editions[0],
15 |
16 | allConferenceEditions: () => config.conference.editions,
17 |
18 | isLatestConferenceEdition: (state, getters) => state.conferenceEdition === getters.latestConferenceEdition,
19 |
20 | conferenceId: state => state.conferenceId,
21 |
22 | conferenceName: (state, getters) => getters.conferenceEdition ? getters.conferenceEdition.name : '',
23 |
24 | conferenceScheduleUrl: state => {
25 | return `${config.conference.urlPrefix}/conferences/${state.conferenceId}.json`
26 | }
27 | },
28 |
29 | mutations: {
30 | setConferenceEdition (state, edition) {
31 | state.conferenceEdition = edition
32 | state.conferenceId = edition ? `${config.conference.id}-${edition.id}` : null
33 | }
34 | },
35 |
36 | actions: {
37 | initConference ({ commit, getters, dispatch }, editionId) {
38 | const edition = editionId ? config.conference.editions.find(edition => edition.id === editionId) : getters.latestConferenceEdition
39 | commit('setConferenceEdition', edition)
40 |
41 | if (edition && !getters.isLatestConferenceEdition) {
42 | dispatch(
43 | 'showNotification',
44 | {
45 | message: 'You are browsing past conference edition.',
46 | timeout: -1,
47 | button: {
48 | title: 'GO TO CURRENT',
49 | handler: () => {
50 | dispatch('switchConferenceEdition', getters.latestConferenceEdition.id)
51 | router.push({ name: 'dashboard', params: { editionId: getters.latestConferenceEdition.id } })
52 | }
53 | }
54 | },
55 | { root: true }
56 | )
57 | }
58 | }
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/components/EventList.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | {{ dayEvents.day.name }}
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
77 |
78 |
80 |
--------------------------------------------------------------------------------
/store/player.js:
--------------------------------------------------------------------------------
1 | import { router } from '@/pages'
2 |
3 | export default {
4 | state: {
5 | playerEvent: null,
6 |
7 | currentEventId: null,
8 |
9 | trackEventNotifiedId: null
10 | },
11 |
12 | getters: {
13 | playerEvent: (state) => state.playerEvent,
14 |
15 | dockedPlayer: (state) => state.playerEvent && state.playerEvent.id === state.currentEventId
16 | },
17 |
18 | mutations: {
19 | setPlayerEvent (state, playerEvent) {
20 | state.playerEvent = playerEvent
21 | },
22 |
23 | setCurrentEventId (state, currentEventId) {
24 | state.currentEventId = currentEventId
25 | },
26 |
27 | setTrackEventNotifiedId (state, eventId) {
28 | state.trackEventNotifiedId = eventId
29 | }
30 | },
31 |
32 | actions: {
33 | play ({ commit, state, dispatch }, event) {
34 | if (!event) {
35 | dispatch('stop')
36 | return
37 | }
38 |
39 | if (state.playerEvent && state.playerEvent.id === event.id) {
40 | return
41 | }
42 |
43 | commit('setPlayerEvent', event)
44 | },
45 |
46 | stop ({ commit, state }) {
47 | if (!state.playerEvent) {
48 | return
49 | }
50 |
51 | commit('setPlayerEvent', null)
52 | },
53 |
54 | dockPlayer ({ commit }, eventId) {
55 | commit('setCurrentEventId', eventId)
56 | },
57 |
58 | notifyTrackEvent ({ state, getters, commit, dispatch, rootGetters }) {
59 | if (!getters.dockedPlayer) {
60 | return
61 | }
62 |
63 | const playerEvent = state.playerEvent
64 | const trackEvent = rootGetters.liveTrackEvent(playerEvent.track.name)
65 |
66 | if (trackEvent && trackEvent.id !== playerEvent.id && trackEvent.startTime > playerEvent.startTime && state.trackEventNotifiedId !== trackEvent.id) {
67 | dispatch('showMessage', 'Advancing to currently streamed event in this track...')
68 |
69 | setTimeout(() => {
70 | commit('setPlayerEvent', trackEvent)
71 | router.push({ name: 'event', params: { eventId: trackEvent.id } })
72 | }, 5000)
73 |
74 | commit('setTrackEventNotifiedId', trackEvent.id)
75 | }
76 | }
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | Sojourner
17 |
18 | <% if (htmlWebpackPlugin.files.chunks.preload) { %>
19 |
20 |
21 | <% } %>
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
38 | <% for (var chunk in htmlWebpackPlugin.files.js) { %>
39 |
40 | <% } %>
41 |
42 |
43 |
--------------------------------------------------------------------------------
/config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 |
3 | conference: {
4 | id: 'fosdem',
5 | name: 'FOSDEM',
6 | urlPrefix: 'https://bo.sojourner.rocks',
7 | editions: [
8 | {
9 | id: '2026',
10 | name: 'FOSDEM 2026',
11 | dates: ['2026-01-31', '2026-02-01']
12 | },
13 | {
14 | id: '2025',
15 | name: 'FOSDEM 2025',
16 | dates: ['2025-02-01', '2025-02-02']
17 | },
18 | {
19 | id: '2024',
20 | name: 'FOSDEM 2024',
21 | dates: ['2024-02-03', '2024-02-04']
22 | },
23 | {
24 | id: '2023',
25 | name: 'FOSDEM 2023',
26 | dates: ['2023-02-04', '2023-02-05']
27 | },
28 | {
29 | id: '2022',
30 | name: 'FOSDEM 2022',
31 | dates: ['2022-02-05', '2022-02-06']
32 | },
33 | {
34 | id: '2021',
35 | name: 'FOSDEM 2021',
36 | dates: ['2021-02-06', '2021-02-07']
37 | },
38 | {
39 | id: '2020',
40 | name: 'FOSDEM 2020',
41 | dates: ['2020-02-01', '2020-02-02']
42 | },
43 | {
44 | id: '2019',
45 | name: 'FOSDEM 2019',
46 | dates: ['2019-02-02', '2019-02-03']
47 | },
48 | {
49 | id: '2018',
50 | name: 'FOSDEM 2018',
51 | dates: ['2019-02-03', '2019-02-04']
52 | }
53 | ]
54 | },
55 | features: {
56 | map: true,
57 | all: false,
58 | live: true,
59 | rooms: true,
60 | localtimes: true
61 | },
62 | colors: {
63 | primary: {
64 | base: '#54BECA',
65 | lighten3: '#DCEBED'
66 | },
67 | secondary: {
68 | base: '#A12F88',
69 | lighten5: '#FFF0E8'
70 | }
71 | },
72 | types: [
73 | {
74 | background: 'background-1.png',
75 | mobileColor: '#7FDBD399',
76 | desktopColor: '#00E3CF',
77 | arrowColor: '#00E3CF'
78 | },
79 | {
80 | background: 'background-2.png',
81 | mobileColor: '#54BECA99',
82 | desktopColor: '#F7F7F7',
83 | arrowColor: '#54BECA'
84 | },
85 | {
86 | background: 'background-3.png',
87 | mobileColor: '#96C3C999',
88 | desktopColor: '#00D4EF',
89 | arrowColor: '#00D4EF'
90 | },
91 | {
92 | background: 'background-4.png',
93 | mobileColor: '#E0D0BF99',
94 | desktopColor: '#DB8529',
95 | arrowColor: '#DB8529'
96 | },
97 | {
98 | background: 'background-5.png',
99 | mobileColor: '#A18A9D99',
100 | desktopColor: '#D900B4',
101 | arrowColor: '#D900B4'
102 | }
103 | ]
104 | }
105 |
--------------------------------------------------------------------------------
/functions/index.js:
--------------------------------------------------------------------------------
1 | if (process.env.NODE_ENV !== 'production') {
2 | require('dotenv').config()
3 | }
4 |
5 | const functions = require('firebase-functions')
6 | const admin = require('firebase-admin')
7 | const { isAdmin } = require('./auth')
8 | // const fetchFosdem = require('./fetch/fosdem')
9 | // const fetchSched = require('./fetch/sched')
10 | // const store = require('./store')
11 | const adminUsers = require('./admin/users')
12 | const adminFavourites = require('./admin/favourites')
13 |
14 | admin.initializeApp({
15 | storageBucket: 'sojourer-web.appspot.com'
16 | })
17 |
18 | /*
19 | exports.storeFosdem = functions.pubsub.schedule('every 3 hours').onRun(async (context) => {
20 | const fosdemConfig = functions.config().fosdem
21 | const year = fosdemConfig.year
22 | const fosdemData = await fetchFosdem(`https://fosdem.org/${year}/schedule/xml`, { year, dates: fosdemConfig.dates.split(',') })
23 | await store(fosdemData, `fosdem-${year}.json`)
24 | })
25 | */
26 |
27 | /*
28 | exports.storeFlowcon = functions.pubsub.schedule('every 5 minutes').onRun(async (context) => {
29 | const flowconData = await fetchSched(functions.config().flowcon.url, functions.config().flowcon.key)
30 | await store(flowconData, 'flowcon-2019.json')
31 | })
32 | */
33 |
34 | /*
35 | const popularity = require('./stats/popularity')
36 | exports.statPopularity2023 = functions.pubsub.schedule('never').onRun(async (context) => {
37 | await popularity('fosdem-2023')
38 | })
39 | exports.statPopularity2022 = functions.pubsub.schedule('never').onRun(async (context) => {
40 | await popularity('fosdem-2022')
41 | })
42 | exports.statPopularity2021 = functions.pubsub.schedule('never').onRun(async (context) => {
43 | await popularity('fosdem-2021')
44 | })
45 | exports.statPopularity2020 = functions.pubsub.schedule('never').onRun(async (context) => {
46 | await popularity('fosdem-2020')
47 | })
48 | exports.statPopularity2019 = functions.pubsub.schedule('never').onRun(async (context) => {
49 | await popularity('fosdem-2019')
50 | })
51 | */
52 |
53 | /*
54 | const migrate = require('./migrate/001-migrateFavouritesToConference')
55 | exports.migrate = functions.pubsub.schedule('never').onRun(async (context) => {
56 | await migrate()
57 | })
58 | */
59 |
60 | exports.adminUsers = functions.https.onRequest(async (req, res) => {
61 | if (!isAdmin(req)) {
62 | res.status(403).send('Forbidden')
63 | return
64 | }
65 |
66 | const users = await adminUsers()
67 |
68 | res.status(200).send(users)
69 | })
70 |
71 | exports.adminFavourites = functions.https.onRequest(async (req, res) => {
72 | if (!isAdmin(req)) {
73 | res.status(403).send('Forbidden')
74 | return
75 | }
76 |
77 | const favourites = await adminFavourites(req.query.conference)
78 |
79 | res.status(200).send(favourites)
80 | })
81 |
--------------------------------------------------------------------------------
/store/state.js:
--------------------------------------------------------------------------------
1 | import RoomState from '@/logic/RoomState'
2 |
3 | export default {
4 | state: {
5 | roomStates: {},
6 |
7 | roomStateUpdaterInitialized: false
8 | },
9 |
10 | getters: {
11 | roomStates: state => state.roomStates,
12 |
13 | roomState: state => roomName => {
14 | const roomState = state.roomStates[roomName]
15 | if (roomState) {
16 | return roomState
17 | } else {
18 | return new RoomState({ room: roomName })
19 | }
20 | }
21 | },
22 |
23 | mutations: {
24 | initializeRoomStateUpdater (state) {
25 | state.roomStateUpdaterInitialized = true
26 | },
27 |
28 | setRoomStates (state, roomStates) {
29 | state.roomStates = roomStates
30 | }
31 | },
32 |
33 | actions: {
34 | async refreshRoomStates ({ commit, dispatch, state }) {
35 | const response = await fetch(process.env.ROOM_STATE_URL, { cache: 'no-store' })
36 |
37 | if (!response.ok) {
38 | throw new Error(`${response.status}: ${response.statusText}`)
39 | }
40 |
41 | const states = await response.json()
42 |
43 | const roomStates = {}
44 | states.forEach(room => {
45 | roomStates[room.roomname] = Object.freeze(new RoomState({
46 | room: room.roomname,
47 | state: parseInt(room.state)
48 | }))
49 | })
50 |
51 | if (JSON.stringify(state.roomStates) !== JSON.stringify(roomStates)) {
52 | commit('setRoomStates', roomStates)
53 |
54 | const emergencyRooms = Object.values(roomStates)
55 | .filter(roomState => roomState.emergency)
56 | .map(roomState => roomState.room)
57 | if (emergencyRooms.length > 0) {
58 | /* dispatch('showNotification', {
59 | message: `Emergency evacuation of rooms: ${emergencyRooms.join(', ')}`,
60 | level: 'warning',
61 | timeout: 0
62 | }) */
63 | }
64 | }
65 | },
66 |
67 | initRoomStateUpdater ({ dispatch, state, commit }) {
68 | if (!process.env.ROOM_STATE_URL || !process.env.ROOM_STATE_INTERVAL || state.roomStateUpdaterInitialized) {
69 | return
70 | }
71 | const pollInterval = parseInt(process.env.ROOM_STATE_INTERVAL)
72 | if (pollInterval <= 0) {
73 | return
74 | }
75 | const scheduleRefreshRoomStates = (attempt = 0) => {
76 | return dispatch('refreshRoomStates')
77 | .then(() => setTimeout(scheduleRefreshRoomStates, pollInterval * 1000))
78 | .catch(() => {
79 | if (attempt > 3) {
80 | commit('setRoomStates', {})
81 | }
82 | setTimeout(() => scheduleRefreshRoomStates(attempt + 1), pollInterval * 1000)
83 | })
84 | }
85 | scheduleRefreshRoomStates()
86 | }
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/components/Event.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | {{ event.day.name }}
7 |
8 |
9 | {{ event.startTime }}-{{ event.endTime }}
10 |
11 |
12 | | {{ event.room.name }}
13 |
14 |
15 |
16 | | {{ event.track.name }}
17 |
18 |
19 | | {{ event.type.name }}
20 |
21 |
22 |
23 | {{ event.title }}
24 |
25 | ({{ event.language }})
26 |
27 |
28 |
29 | {{ event.speakers }}
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
77 |
78 |
101 |
--------------------------------------------------------------------------------
/pages/CampusMap.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
51 |
52 |
128 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "sojourner",
3 | "description": "Conference companion, offline-first progressive web app.",
4 | "version": "2025.1",
5 | "author": "Jarek Lipski ",
6 | "private": true,
7 | "scripts": {
8 | "build": "NODE_ENV=production webpack --hide-modules",
9 | "start": "http-server dist",
10 | "dev": "NODE_ENV=development webpack-dev-server --hot --disable-host-check",
11 | "lint": "eslint --ext .js,.vue --ignore-path .gitignore .",
12 | "test": "NODE_ENV=test ROOM_STATE_INTERVAL=0 karma start --single-run",
13 | "ci": "npm run lint && npm run test && npm run build"
14 | },
15 | "dependencies": {
16 | "@sentry/browser": "^5.30.0",
17 | "@sentry/integrations": "^5.30.0",
18 | "dotenv": "^8.6.0",
19 | "firebase": "^10.7.1",
20 | "git-revision-webpack-plugin": "^3.0.6",
21 | "hls.js": "^0.14.17",
22 | "lodash": "^4.17.21",
23 | "moment": "^2.29.4",
24 | "thenby": "^1.3.4",
25 | "typeface-roboto-condensed": "0.0.75",
26 | "typeface-rubik": "0.0.72",
27 | "vue": "^2.7.14",
28 | "vue-meta": "^2.4.0",
29 | "vue-router": "^3.6.5",
30 | "vue-test-utils": "^1.0.0-beta.11",
31 | "vuetify": "^2.6.14",
32 | "vuex": "^3.6.2",
33 | "workbox-window": "^5.1.4"
34 | },
35 | "devDependencies": {
36 | "@mdi/font": "^7.1.96",
37 | "add-asset-webpack-plugin": "^1.0.0",
38 | "babel-eslint": "^10.1.0",
39 | "chai": "^4.3.7",
40 | "chai-datetime": "^1.8.0",
41 | "clean-webpack-plugin": "^3.0.0",
42 | "copy-webpack-plugin": "^5.1.2",
43 | "css-loader": "^3.6.0",
44 | "deepmerge": "^4.3.0",
45 | "eslint": "^6.8.0",
46 | "eslint-config-standard": "^14.1.1",
47 | "eslint-plugin-chai-friendly": "^0.5.0",
48 | "eslint-plugin-html": "^6.2.0",
49 | "eslint-plugin-import": "^2.27.5",
50 | "eslint-plugin-mocha": "^6.3.0",
51 | "eslint-plugin-node": "^11.1.0",
52 | "eslint-plugin-promise": "^4.3.1",
53 | "eslint-plugin-standard": "^4.1.0",
54 | "eslint-plugin-vue": "^6.2.2",
55 | "eslint-plugin-vuetify": "^1.1.0",
56 | "file-loader": "^6.2.0",
57 | "friendly-errors-webpack-plugin": "^1.7.0",
58 | "html-webpack-plugin": "^3.2.0",
59 | "http-server": "^0.12.3",
60 | "karma": "^6.4.1",
61 | "karma-chai-datetime": "^0.1.1",
62 | "karma-chai-string": "0.0.8",
63 | "karma-chrome-launcher": "^3.1.0",
64 | "karma-mocha": "^2.0.1",
65 | "karma-sinon-chai": "^2.0.2",
66 | "karma-sourcemap-loader": "^0.3.8",
67 | "karma-spec-reporter": "0.0.36",
68 | "karma-webpack": "^4.0.0",
69 | "mini-css-extract-plugin": "^0.9.0",
70 | "mocha": "^10.2.0",
71 | "puppeteer": "^18.2.1",
72 | "sass": "^1.32.13",
73 | "sass-loader": "^10.0.0",
74 | "sinon": "^15.0.1",
75 | "sinon-chai": "^3.7.0",
76 | "style-loader": "^1.3.0",
77 | "stylus": "^0.59.0",
78 | "stylus-loader": "^3.0.2",
79 | "url-loader": "^4.1.1",
80 | "vue-loader": "^15.10.1",
81 | "vue-style-loader": "^4.1.3",
82 | "vue-template-compiler": "^2.7.14",
83 | "webpack": "^4.46.0",
84 | "webpack-bundle-analyzer": "^3.9.0",
85 | "webpack-cli": "^3.3.12",
86 | "webpack-dev-server": "^3.11.3",
87 | "workbox-webpack-plugin": "^5.1.4"
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/functions/stats/popularity.js:
--------------------------------------------------------------------------------
1 | const admin = require('firebase-admin')
2 |
3 | async function fetchEvents (conference) {
4 | const file = await admin.storage().bucket().file(`conferences/${conference}.json`).download()
5 | const schedule = JSON.parse(file)
6 |
7 | const events = Object.fromEntries(schedule.events.map(event => ([event.id, event])))
8 |
9 | return events
10 | }
11 |
12 | module.exports = async function (conference) {
13 | let userCount = 0
14 | let userCountActive = 0
15 | let maxFavourites = 0
16 | let averageFavourites = 0
17 | const favouriteCounts = {}
18 | const favouriteOccurences = {}
19 | const trackOccurences = {}
20 |
21 | const snapshot = await admin.firestore().collection('users').get()
22 | const events = await fetchEvents(conference)
23 |
24 | for (const doc of snapshot.docs) {
25 | const user = doc.data()
26 | const favourites = (user[conference] && user[conference].favourites) || []
27 |
28 | const favouriteCount = favourites.length
29 | favouriteCounts[favouriteCount] = (favouriteCounts[favouriteCount] || 0) + 1
30 | if (favouriteCount > 0) {
31 | userCount++
32 | averageFavourites += favouriteCount
33 | }
34 | if (favouriteCount >= 5) {
35 | userCountActive++
36 | }
37 | if (favouriteCount > maxFavourites) {
38 | maxFavourites = favouriteCount
39 | }
40 |
41 | const tracks = new Set()
42 | for (const favourite of favourites) {
43 | favouriteOccurences[favourite] = (favouriteOccurences[favourite] || 0) + 1
44 |
45 | const event = events[favourite]
46 | if (event && event.track) {
47 | const track = event.track.startsWith('Main Track') || event.track === 'Keynotes' ? 'Main Track & Keynotes' : event.track
48 | tracks.add(track)
49 | }
50 | }
51 |
52 | for (const track of tracks) {
53 | trackOccurences[track] = (trackOccurences[track] || 0) + 1
54 | }
55 | }
56 |
57 | averageFavourites = userCount > 0 ? Math.round(averageFavourites / userCount * 100) / 100 : 0
58 |
59 | console.log(`Users: ${userCount}`)
60 | console.log(`Users Active 5+: ${userCountActive}`)
61 | console.log(`Max Favourites: ${maxFavourites}`)
62 | console.log(`Average Favourites: ${averageFavourites}`)
63 |
64 | const popularEvents = Object.entries(favouriteOccurences)
65 | .map(([favourite, count]) => ({ count, event: events[favourite] }))
66 | .sort((l, r) => r.count - l.count)
67 |
68 | console.log('\nMost popular talks:')
69 | for (let i = 0; i < 13; ++i) {
70 | const { count, event } = popularEvents[i]
71 |
72 | const place = `${i + 1}`.padStart(2, ' ')
73 | const result = `${count} users`.padStart(9, ' ')
74 | const title = event.subtitle ? `${event.title} / ${event.subtitle}` : event.title
75 | const speakers = event.persons.join(', ')
76 |
77 | console.log(`${place}. [${result}] ${title} (${event.track}) (${speakers})`)
78 | }
79 |
80 | const popularTracks = Object.entries(trackOccurences)
81 | .map(([track, count]) => ({ track, count }))
82 | .sort((l, r) => r.count - l.count)
83 |
84 | console.log('\nMost popular tracks:')
85 | for (let i = 0; i < 13; ++i) {
86 | const { count, track } = popularTracks[i]
87 | const place = `${i + 1}`.padStart(2, ' ')
88 | const result = `${count} users`.padStart(9, ' ')
89 |
90 | console.log(`${place}. [${result}] ${track}`)
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/components/PageTitle.vue:
--------------------------------------------------------------------------------
1 |
2 |
27 |
28 |
29 |
89 |
90 |
127 |
--------------------------------------------------------------------------------
/functions/fetch/sched.js:
--------------------------------------------------------------------------------
1 | const axios = require('axios')
2 | const moment = require('moment')
3 | const Event = require('../logic/Event')
4 | const Type = require('../logic/Type')
5 | const Link = require('../logic/Link')
6 |
7 | /* eslint-disable quote-props */
8 | const LINKS = {
9 | '0d2ca077208b59fd4b61429113dfc573': 'https://www.youtube.com/watch?v=39u-WHz-9Cg&list=PLAqbkwjzg7IOr9yJlptM_jMdjIzSONY_1&index=1',
10 | '24911718a8cb47100861344e6f911a90': 'https://www.youtube.com/watch?v=D7GNBYjMxBw&list=PLAqbkwjzg7IOr9yJlptM_jMdjIzSONY_1&index=2',
11 | 'dc2100463b0a8f11c79a162a320fbd72': 'https://www.youtube.com/watch?v=NfyURsZ17l8&list=PLAqbkwjzg7IOr9yJlptM_jMdjIzSONY_1&index=3',
12 | 'e35739aa2946c4d2c623c8b832f57b56': 'https://www.youtube.com/watch?v=E88pnHQAfm0&list=PLAqbkwjzg7IOr9yJlptM_jMdjIzSONY_1&index=4',
13 | '52d9910ec3d4907fbb435bf654adf23f': 'https://www.youtube.com/watch?v=P0VMDomQxQk&list=PLAqbkwjzg7IOr9yJlptM_jMdjIzSONY_1&index=5',
14 | '6b2e0479a790ae48f39191bbb3885ab9': 'https://www.youtube.com/watch?v=re5jkjxBhLo&list=PLAqbkwjzg7IOr9yJlptM_jMdjIzSONY_1&index=6',
15 | '8031487dbf3f73a97600831e1cc16cce': 'https://www.youtube.com/watch?v=dEHtDfQTNcw&list=PLAqbkwjzg7IOr9yJlptM_jMdjIzSONY_1&index=7',
16 | 'b52dd88c2863e9cd65dbf5ca0be2ed76': 'https://www.youtube.com/watch?v=YibNG8xx15c&list=PLAqbkwjzg7IOr9yJlptM_jMdjIzSONY_1&index=9',
17 | '3ca106743d1187ca7f45d2843f95a45f': 'https://www.youtube.com/watch?v=RO8vg8MvAtE&list=PLAqbkwjzg7IOr9yJlptM_jMdjIzSONY_1&index=10',
18 | 'b565f20b2c946608b2b0a329c2a4c2af': 'https://www.youtube.com/watch?v=Fa6cNEB5BMI&list=PLAqbkwjzg7IOr9yJlptM_jMdjIzSONY_1&index=11',
19 | '0fb691d344aa0d4d90e1b6be801d3f30': 'https://www.youtube.com/watch?v=iagaLj_HFCQ&list=PLAqbkwjzg7IOr9yJlptM_jMdjIzSONY_1&index=12',
20 | 'eaf18224a4a4a4956ddb7a767df822d0': 'https://www.youtube.com/watch?v=QsGL1RkmnyU&list=PLAqbkwjzg7IOr9yJlptM_jMdjIzSONY_1&index=13',
21 | 'e05d0c8e09d002308984f96ff3aa88f8': 'https://www.youtube.com/watch?v=dGKAwRodbjA&list=PLAqbkwjzg7IOr9yJlptM_jMdjIzSONY_1&index=14'
22 | }
23 | /* eslint-enable quote-props */
24 |
25 | module.exports = async function (scheduleUrl, scheduleKey) {
26 | const response = await axios.get(scheduleUrl, { params: { api_key: scheduleKey, format: 'json' } })
27 |
28 | const json = response.data
29 |
30 | const typeMap = {}
31 |
32 | const events = json.map((e) => {
33 | const startTime = moment(e.start_time, 'HH:mm:ss')
34 | const endTime = moment(e.end_time, 'HH:mm:ss')
35 | const duration = moment.utc(endTime.diff(startTime))
36 |
37 | const typeId = e.event_type.toLowerCase().replace(/[^[a-z0-9- ]/g, '').replace(/\s+/g, '-')
38 | let type = typeMap[typeId]
39 | if (!type) {
40 | type = {
41 | id: typeId,
42 | name: e.event_type,
43 | sort: e.event_type_sort
44 | }
45 | typeMap[typeId] = type
46 | }
47 |
48 | const room = e.venue.replace(/\s*-\s*\d+p$/, '')
49 |
50 | const links = LINKS[e.id] ? [new Link({ title: 'Video', href: LINKS[e.id] })] : []
51 |
52 | return new Event({
53 | id: e.id,
54 | date: e.start_date,
55 | startTime: startTime.format('HH:mm'),
56 | duration: duration.format('HH:mm'),
57 | title: e.name,
58 | description: e.description,
59 | type: typeId,
60 | track: type.name,
61 | room,
62 | persons: e.speakers ? e.speakers.map(speaker => speaker.name) : [],
63 | language: e.event_subtype,
64 | links
65 | })
66 | })
67 |
68 | const types = Object.values(typeMap)
69 | .sort((l, r) => l.sort - r.sort)
70 | .map(t => new Type({
71 | id: t.id,
72 | name: t.name,
73 | statName: 'events'
74 | }))
75 |
76 | return { types, events }
77 | }
78 |
--------------------------------------------------------------------------------
/pages/SearchEvents.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | No events found.
16 |
17 |
18 |
19 |
20 |
21 | mdi-subdirectory-arrow-left Start typing to search for an event by title, description, track, speaker, room, etc.
22 |
23 |
24 |
25 |
26 |
27 |
114 |
115 |
142 |
--------------------------------------------------------------------------------
/pages/About.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Schedule updated:
10 | {{ lastModified }}
11 |
12 |
13 | Code updated:
14 | {{ timestamp }}
15 |
16 |
17 | Version:
18 |
19 | {{ version }}
20 | (Changelog )
21 |
22 |
23 |
24 | Code / Issues:
25 | Github
26 |
27 |
28 | All editions:
29 |
30 |
31 | {{ edition.id }},
32 |
33 |
34 |
35 |
36 |
37 |
38 | Sojourner is a conference companion, developed by Jarek Lipski .
39 |
40 |
41 | It's inspired by the FOSDEM schedule app written by Will Thompson for the Nokia N900 back in 2010, and discovered by Jarek during his first visit to ULB Solbosch Campus in 2012.
42 |
43 |
44 | The N900's codename was Rover; Sojourner is a Mars rover. Naming it Sojourner seemed apt because a sojourn is a short trip and FOSDEM is 2 days long.
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
87 |
88 |
118 |
--------------------------------------------------------------------------------
/pages/Dashboard.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | {{ type.type.name }}
8 | {{ type.tracks.length > 1 ? type.tracks.length : type.events.length }} {{ type.type.statName }}
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | {{ conferenceName }}
19 |
20 |
21 |
22 |
23 |
24 |
25 | {{ type.type.name }}
26 |
27 | {{ type.tracks.length > 1 ? type.tracks.length : type.events.length }} {{ type.type.statName }}
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
59 |
60 |
113 |
114 |
135 |
--------------------------------------------------------------------------------
/components/MainMenu.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | mdi-account
18 |
19 |
20 | {{ realUser.email }}
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 | mdi-account-outline
31 |
32 | Log-out
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
47 |
48 |
49 |
50 |
93 |
94 |
122 |
--------------------------------------------------------------------------------
/components/Player.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | mdi-seat
7 |
8 |
9 |
10 |
11 | mdi-close
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
104 |
105 |
165 |
--------------------------------------------------------------------------------
/pages/index.js:
--------------------------------------------------------------------------------
1 | import Vue from 'vue'
2 | import Router from 'vue-router'
3 | import Meta from 'vue-meta'
4 |
5 | import config from '@/config'
6 | import store from '@/store'
7 |
8 | import Edition from './Edition'
9 | import Dashboard from './Dashboard'
10 | import About from './About'
11 | import SearchEvents from './SearchEvents'
12 | import BuildingMap from './BuildingMap'
13 | import CampusMap from './CampusMap'
14 | import ConferenceTrackEvents from './ConferenceTrackEvents'
15 | import EventDetails from './EventDetails'
16 | import FavouriteEvents from './FavouriteEvents'
17 | import TypeTracksOrEvents from './TypeTracksOrEvents'
18 | import AllEvents from './AllEvents'
19 | import LiveEvents from './LiveEvents'
20 | import SharedEvents from './SharedEvents'
21 | import Delete from './Delete'
22 |
23 | window.history.scrollRestoration = 'manual'
24 |
25 | Vue.use(Router)
26 | Vue.use(Meta)
27 |
28 | const router = new Router({
29 | mode: 'history',
30 | routes: [
31 | {
32 | path: '/',
33 | redirect: `/${config.conference.editions[0].id}`
34 | },
35 | {
36 | path: '/:editionId(\\d+)',
37 | component: Edition,
38 |
39 | children: [
40 | {
41 | path: '',
42 | name: 'dashboard',
43 | component: Dashboard,
44 | meta: {
45 | layout: 'cover'
46 | }
47 | },
48 |
49 | {
50 | path: 'live',
51 | name: 'live-events',
52 | component: LiveEvents
53 | },
54 |
55 | {
56 | path: 'search',
57 | name: 'search-events',
58 | component: SearchEvents
59 | },
60 |
61 | {
62 | path: 'favourites',
63 | name: 'favourite-events',
64 | component: FavouriteEvents
65 | },
66 |
67 | {
68 | path: 'shared',
69 | name: 'shared-events',
70 | component: SharedEvents,
71 | props: route => ({ eventIds: route.query.eventIds ? route.query.eventIds.split(',') : [], order: !!route.query.order })
72 | },
73 |
74 | {
75 | path: 'all',
76 | name: 'all-events',
77 | component: AllEvents
78 | },
79 |
80 | {
81 | path: 'event/:eventId?',
82 | name: 'event',
83 | component: EventDetails,
84 | props: true
85 | },
86 |
87 | {
88 | path: 'type/:typeName?',
89 | name: 'type',
90 | component: TypeTracksOrEvents,
91 | props: true
92 | },
93 |
94 | {
95 | path: 'track/:trackName?',
96 | name: 'track',
97 | component: ConferenceTrackEvents,
98 | props: true
99 | },
100 |
101 | {
102 | path: 'map',
103 | name: 'campus-map',
104 | component: CampusMap
105 | },
106 |
107 | {
108 | path: 'building/:buildingName?',
109 | name: 'building-map',
110 | component: BuildingMap,
111 | props: true
112 | }
113 | ]
114 | },
115 |
116 | {
117 | path: '/about',
118 | name: 'about',
119 | component: About,
120 | meta: {
121 | layout: 'cover'
122 | }
123 | },
124 |
125 | {
126 | path: '/delete',
127 | name: 'delete',
128 | component: Delete,
129 | meta: {
130 | layout: 'cover'
131 | }
132 | }
133 | ],
134 |
135 | scrollBehavior (to, from, savedPosition) {
136 | if (savedPosition) {
137 | return Vue.nextTick().then(() => savedPosition)
138 | } else {
139 | return { x: 0, y: 0 }
140 | }
141 | }
142 | })
143 |
144 | router.afterEach((to, from) => {
145 | const pathEditionId = to.params.editionId
146 |
147 | const storeEdition = store.getters.conferenceEdition
148 | const storeEditionId = storeEdition && storeEdition.id
149 |
150 | if (pathEditionId && storeEditionId && pathEditionId !== storeEditionId) {
151 | console.log(pathEditionId)
152 | console.log(storeEditionId)
153 | window.location.reload()
154 | }
155 | })
156 |
157 | export {
158 | router
159 | }
160 |
--------------------------------------------------------------------------------
/test/resources/fosdem-2019.json:
--------------------------------------------------------------------------------
1 | {
2 | "types": [
3 | {
4 | "id": "keynote",
5 | "name": "Keynotes",
6 | "statName": "lectures"
7 | }
8 | ],
9 | "events": [
10 | {
11 | "id": "8967",
12 | "date": "2019-02-02",
13 | "startTime": "09:30",
14 | "duration": "00:25",
15 | "title": "Welcome to FOSDEM 2019",
16 | "abstract": "FOSDEM welcome and opening talk.
",
17 | "description": "Welcome to FOSDEM 2019!
",
18 | "type": "keynote",
19 | "track": "Keynotes",
20 | "room": "Janson",
21 | "persons": [
22 | "FOSDEM Staff"
23 | ],
24 | "links": [
25 | {
26 | "href": "https://video.fosdem.org/2019/Janson/keynotes_welcome.webm",
27 | "title": "Video recording (WebM/VP9)"
28 | },
29 | {
30 | "href": "https://video.fosdem.org/2019/Janson/keynotes_welcome.mp4",
31 | "title": "Video recording (mp4)"
32 | },
33 | {
34 | "href": "https://submission.fosdem.org/feedback/8967.php",
35 | "title": "Submit feedback"
36 | }
37 | ]
38 | },
39 | {
40 | "id": "7545",
41 | "date": "2019-02-02",
42 | "startTime": "10:00",
43 | "duration": "00:50",
44 | "title": "Can Anyone Live in Full Software Freedom Today?",
45 | "subtitle": "Confessions of Activists Who Try But Fail to Avoid Proprietary Software",
46 | "abstract": "The FOSS community suffers deeply from a fundamental paradox: every day, there are more lines of freely licensed code than ever in history, but, every day, it also becomes slightly more difficult to operate productively using only Open Source and Free Software.
",
47 | "description": "In one sense, we live in the paramount of success of FOSS: developers can easily find jobs writing mostly freely licensed software. Companies, charities, trade associations, and individual actors collaborate together on the same code bases in (relative) harmony. The entire Internet would cease to function without FOSS. Yet, the \"last mile\" of the most critical software that we rely on in our daily lives is usually proprietary.
\n\nWe, the presenters of this talk, live as the canaries in the coalmine of proprietary software. We have spent our lives seeking to actively avoid proprietary software but both personally and professionally, we find ourselves making compromises. In this talk, we will report the results of our diligent efforts to use only FOSS in our daily work.
\n\nIdeally, it would be possible to live a software freedom lifestyle in the way a vegetarian lives a vegetarian lifestyle: minor inconveniences at some restaurants and stores, but generally most industrialized societies provide opportunity and resources to support that moral choice. Not so with proprietary software: often, the compromise is between \"spend hours or days for a task that would take mere minutes with proprietary software\". In other cases, important opportunities are simply not offered to those who chose software freedom.
\n\nThe advent of network services, which mix server-side secret software, and proprietary Javascript or \"Apps\", are central to the decline in the ability to live a productive, convenient life in software freedom. However, few in our community focus on the implications of this and related problems, and few now even try to resist. We have tried to resist, and while we have succeeded occasionally, we have failed overall to live life in software freedom.
\n\nIn this talk, we will report on where the resistance fails the most and why. Finally, we will make suggestions of where volunteer developers can most strategically focus their efforts to build a world where all can live in software freedom.
",
48 | "type": "keynote",
49 | "track": "Keynotes",
50 | "room": "Janson",
51 | "persons": [
52 | "Bradley M. Kuhn",
53 | "Karen Sandler"
54 | ],
55 | "links": [
56 | {
57 | "href": "https://video.fosdem.org/2019/Janson/full_software_freedom.webm",
58 | "title": "Video recording (WebM/VP9)"
59 | },
60 | {
61 | "href": "https://video.fosdem.org/2019/Janson/full_software_freedom.mp4",
62 | "title": "Video recording (mp4)"
63 | },
64 | {
65 | "href": "https://submission.fosdem.org/feedback/7545.php",
66 | "title": "Submit feedback"
67 | }
68 | ]
69 | }
70 | ]
71 | }
72 |
--------------------------------------------------------------------------------
/store/meta.js:
--------------------------------------------------------------------------------
1 | import config from '@/config'
2 |
3 | function isStandalone () {
4 | return window.navigator.standalone === true || window.matchMedia('(display-mode: standalone)').matches
5 | }
6 |
7 | function isLocalStorageAvailable () {
8 | try {
9 | window.localStorage.setItem('__storage_test__', 0)
10 | window.localStorage.removeItem('__storage_test__')
11 | return true
12 | } catch (e) {
13 | return false
14 | }
15 | }
16 |
17 | function getDate () {
18 | return new Date().toISOString().substring(0, 10)
19 | }
20 |
21 | function canShowA2HSTip () {
22 | if (!isLocalStorageAvailable()) {
23 | return
24 | }
25 |
26 | const value = window.localStorage.getItem('a2hsTip')
27 |
28 | return value !== getDate()
29 | }
30 |
31 | function shownA2HSTip () {
32 | window.localStorage.setItem('a2hsTip', getDate())
33 | }
34 |
35 | export default {
36 | state: {
37 | title: null,
38 |
39 | pageTitle: null,
40 |
41 | drawer: null,
42 |
43 | tab: 0
44 | },
45 |
46 | getters: {
47 | timestamp: () => process.env.TIMESTAMP,
48 |
49 | commithash: () => process.env.COMMITHASH,
50 |
51 | version: () => process.env.VERSION,
52 |
53 | title: state => state.title,
54 |
55 | pageTitle: state => state.pageTitle || state.title,
56 |
57 | drawer: state => state.drawer,
58 |
59 | tab: state => state.tab,
60 |
61 | hasAll: state => config.features.all,
62 |
63 | hasRooms: () => config.features.rooms,
64 |
65 | hasLive: (state, getters, rootState, rootGetters) => config.features.live && rootGetters.isLatestConferenceEdition
66 | },
67 |
68 | mutations: {
69 | setTitle (state, title) {
70 | state.title = title
71 | },
72 |
73 | setPageTitle (state, pageTitle) {
74 | state.pageTitle = pageTitle
75 | },
76 |
77 | setDrawer (state, drawer) {
78 | state.drawer = drawer
79 | },
80 |
81 | setTab (state, tab) {
82 | state.tab = tab
83 | }
84 | },
85 |
86 | actions: {
87 | initMeta ({ commit, rootGetters }) {
88 | if (getDate() === rootGetters.conferenceEdition.dates[1]) {
89 | commit('setTab', 1)
90 | }
91 | },
92 |
93 | setTitle ({ commit }, title) {
94 | commit('setTitle', title)
95 | },
96 |
97 | setPageTitle ({ commit }, pageTitle) {
98 | commit('setPageTitle', pageTitle)
99 | },
100 |
101 | setDrawer ({ commit }, drawer) {
102 | commit('setDrawer', drawer)
103 | },
104 |
105 | toggleDrawer ({ commit, state }) {
106 | commit('setDrawer', !state.drawer)
107 | },
108 |
109 | setTab ({ commit }, tab) {
110 | commit('setTab', tab)
111 | },
112 |
113 | initA2HSTip ({ commit, state, dispatch }) {
114 | if (!canShowA2HSTip()) {
115 | return
116 | }
117 |
118 | if (window.onbeforeinstallprompt !== undefined) {
119 | const handler = (e) => {
120 | e.preventDefault()
121 | let deferredPrompt = e
122 |
123 | setTimeout(() => {
124 | shownA2HSTip()
125 |
126 | dispatch('showNotification', {
127 | message: 'If you enjoy using this application, please consider installing it.',
128 | timeout: -1,
129 | button: {
130 | title: 'INSTALL',
131 | handler: () => {
132 | window.removeEventListener('beforeinstallprompt', handler)
133 | deferredPrompt.prompt()
134 | deferredPrompt.userChoice.then(choice => {
135 | console.log(`User ${choice.outcome} the A2HS prompt`)
136 | })
137 | deferredPrompt = null
138 | }
139 | }
140 | })
141 | }, 60 * 1000)
142 | }
143 | window.addEventListener('beforeinstallprompt', handler)
144 | } else if (navigator.userAgent.match(/Mobile|Tablet/) && !isStandalone()) {
145 | setTimeout(() => {
146 | shownA2HSTip()
147 |
148 | dispatch('showNotification', {
149 | message: 'If you enjoy using this application, please consider installing it.',
150 | timeout: -1
151 | })
152 | }, 90 * 1000)
153 | }
154 | },
155 |
156 | notifyNewVersion ({ dispatch }) {
157 | dispatch('showNotification', {
158 | message: 'New version is available.',
159 | timeout: -1,
160 | button: {
161 | title: 'REFRESH',
162 | handler: () => {
163 | window.location.reload()
164 | }
165 | }
166 | })
167 | }
168 |
169 | }
170 | }
171 |
--------------------------------------------------------------------------------
/layout/App.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
123 |
124 |
146 |
147 |
197 |
--------------------------------------------------------------------------------
/store/favourite.js:
--------------------------------------------------------------------------------
1 | import Vue from 'vue'
2 | import { updateDoc, arrayUnion, arrayRemove, serverTimestamp } from 'firebase/firestore'
3 |
4 | import { router } from '@/pages'
5 |
6 | export default {
7 | state: {
8 | favourites: {},
9 |
10 | favouriteTracks: {}
11 | },
12 |
13 | getters: {
14 | favourites: (state) => state.favourites,
15 |
16 | favouriteTracks: (state) => state.favouriteTracks,
17 |
18 | favouritesPath: (state, getters, rootState, rootGetters) => `${rootGetters.conferenceId}.favourites`,
19 |
20 | favouriteUpdatesPath: (state, getters, rootState, rootGetters) => `${rootGetters.conferenceId}.favouriteUpdates`,
21 |
22 | favouriteTracksPath: (state, getters, rootState, rootGetters) => `${rootGetters.conferenceId}.favouriteTracks`
23 | },
24 |
25 | mutations: {
26 | setFavourites (state, favourites) {
27 | state.favourites = favourites
28 | },
29 |
30 | setFavourite (state, eventId) {
31 | Vue.set(state.favourites, eventId, true)
32 | },
33 |
34 | unsetFavourite (state, eventId) {
35 | Vue.delete(state.favourites, eventId)
36 | },
37 |
38 | setFavouriteTracks (state, favourites) {
39 | state.favouriteTracks = favourites
40 | },
41 |
42 | setFavouriteTrack (state, trackName) {
43 | Vue.set(state.favouriteTracks, trackName, true)
44 | },
45 |
46 | unsetFavouriteTrack (state, trackName) {
47 | Vue.delete(state.favouriteTracks, trackName)
48 | }
49 | },
50 |
51 | actions: {
52 | async setFavourite ({ dispatch, getters }, eventId) {
53 | const user = await dispatch('getUserRef')
54 | await updateDoc(user, { [getters.favouritesPath]: arrayUnion(eventId) })
55 | dispatch('touchFavourites', eventId)
56 | },
57 |
58 | async setExistingFavourites ({ state, dispatch, getters }) {
59 | const existingFavourites = Object.keys(state.favourites).map(eventId => eventId)
60 | if (existingFavourites.length > 0) {
61 | const user = await dispatch('getUserRef')
62 | await updateDoc(user, { [getters.favouritesPath]: arrayUnion(...existingFavourites) })
63 | dispatch('touchFavourites', existingFavourites)
64 | }
65 | },
66 |
67 | async unsetFavourite ({ dispatch, getters }, eventId) {
68 | const user = await dispatch('getUserRef')
69 | await updateDoc(user, { [getters.favouritesPath]: arrayRemove(eventId) })
70 | dispatch('touchFavourites', eventId)
71 | },
72 |
73 | async touchFavourites ({ dispatch, getters }, eventIds) {
74 | const update = {}
75 | for (const eventId of Array.isArray(eventIds) ? eventIds : [eventIds]) {
76 | update[`${getters.favouriteUpdatesPath}.${eventId}`] = serverTimestamp()
77 | }
78 |
79 | const user = await dispatch('getUserRef')
80 | await updateDoc(user, update)
81 | },
82 |
83 | toggleFavourite ({ state, dispatch }, eventId) {
84 | if (state.favourites[eventId]) {
85 | return dispatch('unsetFavourite', eventId)
86 | } else {
87 | return dispatch('setFavourite', eventId)
88 | }
89 | },
90 |
91 | async setFavouriteTrack ({ dispatch, getters }, trackName) {
92 | const user = await dispatch('getUserRef')
93 | await updateDoc(user, { [getters.favouriteTracksPath]: arrayUnion(trackName) })
94 | },
95 |
96 | async setExistingFavouriteTracks ({ state, dispatch, getters }) {
97 | const existingFavouriteTracks = Object.keys(state.favouriteTracks).map(trackName => trackName)
98 | if (existingFavouriteTracks.length > 0) {
99 | const user = await dispatch('getUserRef')
100 | await updateDoc(user, { [getters.favouriteTracksPath]: arrayUnion(...existingFavouriteTracks) })
101 | }
102 | },
103 |
104 | async unsetFavouriteTrack ({ dispatch, getters }, trackName) {
105 | const user = await dispatch('getUserRef')
106 | await updateDoc(user, { [getters.favouriteTracksPath]: arrayRemove(trackName) })
107 | },
108 |
109 | toggleFavouriteTrack ({ state, dispatch }, trackName) {
110 | if (state.favouriteTracks[trackName]) {
111 | return dispatch('unsetFavouriteTrack', trackName)
112 | } else {
113 | return dispatch('setFavouriteTrack', trackName)
114 | }
115 | },
116 |
117 | async shareFavourites ({ state, dispatch }) {
118 | const eventIds = Object.keys(state.favourites).join(',')
119 | const route = router.resolve({ name: 'shared-events', query: { eventIds } })
120 | const url = new URL(route.href, window.location.origin).href
121 |
122 | if ('share' in navigator) {
123 | await navigator.share({ url })
124 | } else {
125 | await navigator.clipboard.writeText(url)
126 | dispatch('showMessage', 'Copied share link to the clipboard')
127 | }
128 | }
129 | }
130 | }
131 |
--------------------------------------------------------------------------------
/store/user.js:
--------------------------------------------------------------------------------
1 | import { getAuth, onAuthStateChanged, createUserWithEmailAndPassword, signInWithEmailAndPassword, signInAnonymously, signOut } from 'firebase/auth'
2 | import { getFirestore, doc, setDoc, onSnapshot } from 'firebase/firestore'
3 |
4 | function getUserRefHelper (user) {
5 | return doc(getFirestore(), 'users', user.uid)
6 | }
7 |
8 | export default {
9 | state: {
10 | user: null,
11 |
12 | userUnsubscribe: null,
13 |
14 | userInitialized: false,
15 |
16 | loginDialog: false,
17 |
18 | persistent: false
19 | },
20 |
21 | getters: {
22 | user: state => state.user,
23 |
24 | realUser: state => (state.user && !state.user.isAnonymous) ? state.user : null,
25 |
26 | userInitialized: state => state.userInitialized,
27 |
28 | loginDialog: state => state.loginDialog,
29 |
30 | persistent: state => state.persistent
31 | },
32 |
33 | mutations: {
34 | setUser (state, user) {
35 | state.user = user
36 | },
37 |
38 | setUserInitialized (state, userInitialized) {
39 | state.userInitialized = userInitialized
40 | },
41 |
42 | setUserUnsubscribe (state, userUnsubscribe) {
43 | state.userUnsubscribe = userUnsubscribe
44 | },
45 |
46 | setLoginDialog (state, loginDialog) {
47 | state.loginDialog = loginDialog
48 | },
49 |
50 | setPersistent (state, persistent) {
51 | state.persistent = persistent
52 | }
53 | },
54 |
55 | actions: {
56 | showLoginDialog ({ commit }) {
57 | commit('setLoginDialog', true)
58 | },
59 |
60 | hideLoginDialog ({ commit }) {
61 | commit('setLoginDialog', false)
62 | },
63 |
64 | initUser ({ commit, dispatch, state, rootGetters }) {
65 | onAuthStateChanged(getAuth(), async user => {
66 | if (state.userUnsubscribe) {
67 | await state.userUnsubscribe()
68 | commit('setUserUnsubscribe', null)
69 | }
70 |
71 | commit('setUser', user)
72 | if (user) {
73 | console.log(`Initializing user ${user.uid}`)
74 |
75 | await dispatch('setExistingFavourites')
76 | await dispatch('setExistingFavouriteTracks')
77 |
78 | const userUnsubscribe = onSnapshot(getUserRefHelper(user), user => {
79 | if (!user || !user.data() || !user.data()[rootGetters.conferenceId]) {
80 | return
81 | }
82 | const conference = user.data()[rootGetters.conferenceId]
83 |
84 | if (conference.favourites) {
85 | const favourites = {}
86 | conference.favourites.forEach(favourite => {
87 | favourites[favourite] = true
88 | })
89 | commit('setFavourites', favourites)
90 | }
91 |
92 | if (conference.favouriteTracks) {
93 | const favouriteTracks = {}
94 | conference.favouriteTracks.forEach(favouriteTrack => {
95 | favouriteTracks[favouriteTrack] = true
96 | })
97 | commit('setFavouriteTracks', favouriteTracks)
98 | }
99 |
100 | commit('setUserInitialized', true)
101 | })
102 | commit('setUserUnsubscribe', userUnsubscribe)
103 | } else {
104 | commit('setFavourites', {})
105 | commit('setFavouriteTracks', {})
106 | commit('setUserInitialized', false)
107 | }
108 | })
109 | },
110 |
111 | async register ({ commit, rootGetters, dispatch }, { email, password }) {
112 | const response = await createUserWithEmailAndPassword(getAuth(), email, password)
113 | await setDoc(getUserRefHelper(response.user), {})
114 | },
115 |
116 | logIn ({ commit, rootGetters, dispatch }, { email, password }) {
117 | return signInWithEmailAndPassword(getAuth(), email, password)
118 | },
119 |
120 | logOut ({ commit }) {
121 | return signOut(getAuth())
122 | },
123 |
124 | async getUserRef ({ state }) {
125 | if (state.user) {
126 | return getUserRefHelper(state.user)
127 | } else {
128 | const response = await signInAnonymously(getAuth())
129 | const user = getUserRefHelper(response.user)
130 | await setDoc(user, {}, { merge: true })
131 | return user
132 | }
133 | },
134 |
135 | async initPersistent ({ commit, dispatch }) {
136 | if (navigator.storage && navigator.storage.persisted) {
137 | const persistent = await navigator.storage.persisted()
138 | if (persistent) {
139 | commit('setPersistent', true)
140 | }
141 | } else {
142 | commit('setPersistent', false)
143 | }
144 | },
145 |
146 | async persist ({ commit }) {
147 | if (navigator.storage && navigator.storage.persist) {
148 | const persistent = await navigator.storage.persist()
149 |
150 | if (persistent) {
151 | commit('setPersistent', true)
152 | } else {
153 | throw new Error('Could not enable persistence')
154 | }
155 | } else {
156 | return Promise.reject(new Error('Persistence not supported by the browser'))
157 | }
158 | }
159 | }
160 | }
161 |
--------------------------------------------------------------------------------
/functions/fetch/fosdem.js:
--------------------------------------------------------------------------------
1 | const { DOMParser } = require('xmldom')
2 | const xmltojson = require('xmltojson')
3 | xmltojson.stringToXML = (string) => new DOMParser().parseFromString(string, 'text/xml')
4 |
5 | const axios = require('axios')
6 | const Event = require('../logic/Event')
7 | const Type = require('../logic/Type')
8 | const Link = require('../logic/Link')
9 | const Video = require('../logic/Video')
10 |
11 | const flattenAttributes = (element) => {
12 | if (element instanceof Array) {
13 | return element.map(flattenAttributes)
14 | }
15 |
16 | if (element instanceof Object) {
17 | const keys = Object.keys(element)
18 |
19 | if (keys.length === 1) {
20 | const key = keys[0]
21 | if (key === 'value') {
22 | return element[key]
23 | }
24 | }
25 |
26 | const newElement = {}
27 | keys.forEach(e => {
28 | newElement[e] = flattenAttributes(element[e])
29 | })
30 | return newElement
31 | }
32 |
33 | return element
34 | }
35 |
36 | const getText = (element) => element && element[0] && element[0].text && element[0].text[0] !== null ? element[0].text : undefined
37 |
38 | const getType = (event, typeSet) => {
39 | const type = getText(event.type)
40 | if (!typeSet.has(type)) {
41 | return 'other'
42 | }
43 | return type
44 | }
45 |
46 | const getRoomName = (room) => {
47 | if (room.name.startsWith('D.')) {
48 | return `${room.name} (online)`
49 | }
50 |
51 | return room.name
52 | }
53 |
54 | const getVideoType = (url) => {
55 | if (url.endsWith('.mp4')) {
56 | return 'video/mp4'
57 | } else if (url.endsWith('.webm')) {
58 | return 'video/webm'
59 | } else {
60 | return null
61 | }
62 | }
63 |
64 | const createEvent = (event, type, date, room, { year, dates }) => {
65 | const persons = event.persons && event.persons[0] && event.persons[0].person
66 | ? event.persons[0].person.map(person => person.text) : []
67 | const allLinks = event.links && event.links[0] && event.links[0].link
68 | ? event.links[0].link.map(link => new Link({ href: link.href, title: link.text })) : []
69 |
70 | const links = []
71 | const videos = []
72 | for (const link of allLinks) {
73 | const videoType = getVideoType(link.href)
74 | if (link.title.startsWith('Video recording') && videoType) {
75 | videos.push(new Video({
76 | type: videoType,
77 | url: link.href
78 | }))
79 | } else {
80 | links.push(link)
81 | }
82 | }
83 |
84 | const live = dates.includes(new Date().toISOString().substring(0, 10))
85 | if (live && !room.startsWith('B.') && !room.startsWith('I.') && !room.startsWith('S.')) {
86 | const normalizedRoom = room.toLowerCase().replace(/\./g, '')
87 | const type = 'application/vnd.apple.mpegurl'
88 | const url = `https://stream.fosdem.org/${normalizedRoom}.m3u8`
89 | videos.push(new Video({
90 | type,
91 | url
92 | }))
93 | }
94 |
95 | let title = getText(event.title)
96 | if (title.startsWith('CANCELLED')) {
97 | return null
98 | }
99 | if (title.startsWith('AMENDMENT')) {
100 | title = title.substring(10)
101 | }
102 |
103 | let track = getText(event.track)
104 | if (type === 'other' && track.endsWith('stand')) {
105 | return null
106 | }
107 | if (track.endsWith(' devroom')) {
108 | track = track.substring(0, track.length - ' devroom'.length)
109 | }
110 |
111 | // const chat = /^[A-Z]\./.test(room) ? `https://chat.fosdem.org/#/room/#${year}-space-${room.substring(2)}:fosdem.org` : null
112 | const chat = null
113 |
114 | return new Event({
115 | id: event.id.toString(),
116 | startTime: getText(event.start),
117 | duration: getText(event.duration),
118 | title: title,
119 | subtitle: getText(event.subtitle),
120 | abstract: getText(event.abstract),
121 | description: getText(event.description),
122 | type: type,
123 | track: track,
124 | date: date,
125 | room: room,
126 | persons: persons,
127 | links: links,
128 | videos,
129 | chat
130 | })
131 | }
132 |
133 | module.exports = async function (scheduleUrl, { year, dates }) {
134 | const response = await axios.get(scheduleUrl)
135 |
136 | const json = xmltojson.parseString(response.data, {
137 | attrKey: '',
138 | textKey: 'text',
139 | valueKey: 'value',
140 | attrsAsObject: false
141 | })
142 |
143 | const schedule = flattenAttributes(json.schedule)
144 |
145 | const types = [
146 | new Type({
147 | id: 'keynote',
148 | name: 'Keynotes',
149 | statName: 'lectures'
150 | }),
151 | new Type({
152 | id: 'maintrack',
153 | name: 'Main tracks',
154 | statName: 'tracks'
155 | }),
156 | new Type({
157 | id: 'devroom',
158 | name: 'Developer rooms',
159 | statName: 'rooms'
160 | }),
161 | new Type({
162 | id: 'lightningtalk',
163 | name: 'Lightning talks',
164 | statName: 'talks'
165 | }),
166 | new Type({
167 | id: 'other',
168 | name: 'Other events',
169 | statName: 'events'
170 | })
171 | ]
172 |
173 | const typeSet = new Set(types.map(type => type.id))
174 |
175 | const events = []
176 |
177 | for (const d of schedule[0].day || []) {
178 | const date = d.date
179 | for (const r of d.room || []) {
180 | if (r.event && r.event.length > 0) {
181 | const room = getRoomName(r)
182 | for (const e of r.event || []) {
183 | const type = getType(e, typeSet)
184 | const event = createEvent(e, type, date, room, { year, dates })
185 | if (event) {
186 | events.push(event)
187 | }
188 | }
189 | }
190 | }
191 | }
192 |
193 | events.sort((l, r) => l.id === r.id ? 0 : l.id < r.id ? -1 : 1)
194 |
195 | return { types, events }
196 | }
197 |
--------------------------------------------------------------------------------
/components/MainToolbar.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | mdi-arrow-left
6 |
7 |
8 | {{ pageTitle }}
9 |
10 |
11 |
12 |
13 | mdi-arrow-up
14 |
15 |
16 |
17 |
18 | mdi-arrow-down
19 |
20 |
21 |
22 |
23 | mdi-share-variant
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | {{ conferenceName }}
32 |
33 |
34 |
35 |
36 | Programme
37 |
38 |
39 | Live
40 |
41 |
42 | Bookmarks
43 |
44 |
45 | All
46 |
47 |
48 | Map
49 |
50 |
51 | Search
52 |
53 |
54 | About
55 |
56 |
57 |
58 | mdi-account-outline
59 |
60 |
61 |
62 |
63 |
64 |
65 | mdi-account
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 | {{ realUser.email }}
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 | mdi-account-outline
83 |
84 | Log-out
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
158 |
159 |
228 |
--------------------------------------------------------------------------------
/components/LoginDialog.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Log-in
5 | Register
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | Log-in
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | You don't have an account yet? Register instead.
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | Cancel
40 |
41 |
42 | Log-in
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 | Register
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 | You already have an account? Log-in instead.
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 | Cancel
85 |
86 |
87 | Register
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
204 |
205 |
207 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path')
2 | const webpack = require('webpack')
3 | const moment = require('moment')
4 |
5 | const { CleanWebpackPlugin } = require('clean-webpack-plugin')
6 | const CopyWebpackPlugin = require('copy-webpack-plugin')
7 | const { GenerateSW } = require('workbox-webpack-plugin')
8 | const GitRevisionPlugin = require('git-revision-webpack-plugin')
9 | const HtmlWebpackPlugin = require('html-webpack-plugin')
10 | const VueLoaderPlugin = require('vue-loader/lib/plugin')
11 | const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer')
12 | const MiniCssExtractPlugin = require('mini-css-extract-plugin')
13 | const AddAssetPlugin = require('add-asset-webpack-plugin')
14 | const config = require('./config')
15 |
16 | const gitRevisionPlugin = new GitRevisionPlugin()
17 |
18 | const dotenv = require('dotenv')
19 | dotenv.config()
20 |
21 | const ICON_SIZES = [56, 112, 192, 224, 512]
22 |
23 | const manifest = () => JSON.stringify({
24 | id: 'sojourner',
25 | short_name: 'Sojourner',
26 | name: `Sojourner - ${config.conference.name} Conference Companion`,
27 | icons: ICON_SIZES.map(size => ({
28 | src: `/assets/sojourner-icon-app-${size}.png`,
29 | sizes: `${size}x${size}`,
30 | type: 'image/png',
31 | purpose: 'maskable any'
32 | })),
33 | start_url: '/',
34 | display: 'standalone',
35 | orientation: 'portrait',
36 | background_color: config.colors.primary.base,
37 | theme_color: config.colors.primary.base,
38 | categories: ['utilities', 'education', 'social'],
39 | lang: 'en',
40 | dir: 'ltr',
41 | description: `Sojourner is a ${config.conference.name} conference companion progressive web app (PWA).`,
42 | screenshots: [
43 | {
44 | src: '/screenshots/dashboard.png',
45 | sizes: '563x1218',
46 | type: 'image/png',
47 | label: 'Dashboard'
48 | },
49 | {
50 | src: '/screenshots/devrooms.png',
51 | sizes: '563x1218',
52 | type: 'image/png',
53 | label: 'Tracks'
54 | },
55 | {
56 | src: '/screenshots/events.png',
57 | sizes: '563x1218',
58 | type: 'image/png',
59 | label: 'Track events'
60 | },
61 | {
62 | src: '/screenshots/search.png',
63 | sizes: '563x1218',
64 | type: 'image/png',
65 | label: 'Search'
66 | }
67 | ]
68 | }, null, 2)
69 |
70 | module.exports = {
71 | context: path.resolve(__dirname, '.'),
72 | entry: './main.js',
73 | output: {
74 | path: path.resolve(__dirname, './dist'),
75 | publicPath: '/',
76 | filename: '[name].js',
77 | devtoolModuleFilenameTemplate: info => info.resourcePath.match(/^\.\/\S*?\.vue$/)
78 | ? `webpack-generated:///${info.resourcePath}?${info.hash}`
79 | : `webpack-code:///${info.resourcePath}`,
80 | devtoolFallbackModuleFilenameTemplate: 'webpack:///[resource-path]?[hash]'
81 | },
82 | resolve: {
83 | extensions: ['.js', '.vue', '.json'],
84 | alias: {
85 | vue$: 'vue/dist/vue.esm.js',
86 | '@': path.resolve(__dirname),
87 | assets: path.resolve(__dirname, 'assets')
88 | }
89 | },
90 | mode: 'development',
91 | stats: {
92 | children: false
93 | },
94 | module: {
95 | rules: [
96 | {
97 | test: /\.vue$/,
98 | loader: 'vue-loader',
99 | options: {
100 | }
101 | },
102 | {
103 | test: /preload\.css$/,
104 | use: [
105 | MiniCssExtractPlugin.loader,
106 | 'css-loader'
107 | ]
108 | },
109 | {
110 | test: /\.css$/,
111 | exclude: /preload\.css$/,
112 | use: [
113 | 'vue-style-loader',
114 | 'css-loader'
115 | ]
116 | },
117 | {
118 | test: /\.(png|jpg|gif|svg)$/,
119 | use: [
120 | {
121 | loader: 'url-loader',
122 | options: {
123 | name: '[path][name].[ext]',
124 | limit: 8192,
125 | esModule: false
126 | }
127 | }
128 | ]
129 | },
130 | {
131 | test: /\.(woff|woff2|ttf|eot)$/,
132 | use: [
133 | {
134 | loader: 'file-loader',
135 | options: {
136 | name: 'fonts/[name].[ext]'
137 | }
138 | }
139 | ]
140 | }
141 | ]
142 | },
143 | devServer: {
144 | historyApiFallback: true
145 | },
146 | performance: {
147 | hints: false
148 | },
149 | devtool: 'eval-source-map',
150 | plugins: [
151 | new VueLoaderPlugin(),
152 | new CleanWebpackPlugin(),
153 | new CopyWebpackPlugin([
154 | 'static/',
155 | 'assets/*'
156 | ]),
157 | new AddAssetPlugin('assets/manifest.json', manifest),
158 | new webpack.EnvironmentPlugin({
159 | TIMESTAMP: moment().utcOffset('+0100').format('YYYY-MM-DD HH:mm'),
160 | COMMITHASH: gitRevisionPlugin.commithash(),
161 | VERSION: process.env.npm_package_version,
162 | SCHEDULE_INTERVAL: 0,
163 | ROOM_STATE_URL: '',
164 | ROOM_STATE_INTERVAL: 0,
165 | ANALYTICS_URL: '',
166 | SENTRY_DSN: '',
167 | FIREBASE_API_KEY: undefined,
168 | FIREBASE_AUTH_DOMAIN: undefined,
169 | FIREBASE_DATABASE_URL: undefined,
170 | FIREBASE_PROJECT_ID: undefined,
171 | FIREBASE_STORAGE_BUCKET: undefined,
172 | FIREBASE_MESSAGING_SENDER_ID: undefined
173 | }),
174 | new MiniCssExtractPlugin({
175 | filename: '[name].[contenthash].css'
176 | }),
177 | new HtmlWebpackPlugin({
178 | template: 'index.html',
179 | inject: false
180 | }),
181 | new BundleAnalyzerPlugin({
182 | analyzerMode: 'disabled'
183 | }),
184 | new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
185 | new GenerateSW({
186 | cacheId: 'sojourner',
187 | clientsClaim: true,
188 | skipWaiting: true,
189 | navigateFallback: '/index.html',
190 | exclude: [/\.map$/, /^manifest.*\.js.*$/, '_redirects', '_headers'],
191 | runtimeCaching: [
192 | {
193 | urlPattern: new RegExp('^.*/conferences/.*json$'),
194 | handler: 'StaleWhileRevalidate',
195 | options: {
196 | broadcastUpdate: {
197 | channelName: 'workbox'
198 | },
199 | matchOptions: {
200 | ignoreVary: true
201 | }
202 | }
203 | }
204 | ]
205 | })
206 | ]
207 | }
208 |
209 | if (process.env.NODE_ENV === 'test') {
210 | module.exports.devtool = 'inline-source-map'
211 | }
212 |
213 | if (process.env.NODE_ENV === 'production') {
214 | module.exports.mode = 'production'
215 | module.exports.output.filename = '[name].[contenthash].js'
216 | module.exports.devtool = 'source-map'
217 | module.exports.optimization = {
218 | splitChunks: {
219 | chunks: 'all',
220 | maxInitialRequests: Infinity,
221 | minSize: 0,
222 | cacheGroups: {
223 | styles: {
224 | name: 'preload',
225 | test: /preload\.css$/,
226 | chunks: 'all',
227 | enforce: true
228 | },
229 | commons: {
230 | test: /[\\/]node_modules[\\/]/,
231 | chunks: 'all',
232 | name (module) {
233 | const packageName = module.context.match(/[\\/]node_modules[\\/](.*?)([\\/]|$)/)[1]
234 | // npm package names are URL-safe, but some servers don't like @ symbols
235 | return `npm.${packageName.replace('@', '')}`
236 | }
237 | }
238 | }
239 | }
240 | }
241 | module.exports.plugins = (module.exports.plugins || []).concat([
242 | new webpack.DefinePlugin({
243 | 'process.env.NODE_ENV': JSON.stringify('production')
244 | }),
245 | new webpack.LoaderOptionsPlugin({
246 | minimize: true
247 | })
248 | ])
249 | }
250 |
--------------------------------------------------------------------------------
/pages/EventDetails.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | {{ event.day.name }} {{ event.startTime }}-{{ event.endTime }}
10 |
11 | |
12 | {{ event.room.name }}
13 | {{ event.room.name }}
14 |
15 |
16 |
17 | {{ event.title }}
18 | {{ event.subtitle }}
19 |
20 |
21 | {{ event.speakers }}
22 |
23 |
28 |
29 |
30 |
31 |
32 |
33 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
54 |
59 |
60 |
61 |
62 | {{ event.speakers }}
63 |
64 |
65 | {{ event.title }}
66 |
67 |
68 | {{ event.subtitle }}
69 |
70 |
71 | {{ event.day.name }} {{ event.startTime }}-{{ event.endTime }}
72 |
73 | |
74 | {{ event.room.name }}
75 | {{ event.room.name }}
76 |
77 |
78 |
79 |
80 |
81 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
179 |
180 |
295 |
--------------------------------------------------------------------------------
/store/schedule.js:
--------------------------------------------------------------------------------
1 | import firstBy from 'thenby'
2 | import groupBy from 'lodash/groupBy'
3 | import uniqBy from 'lodash/uniqBy'
4 | import moment from 'moment'
5 |
6 | import Day from '@/logic/Day'
7 | import Event from '@/logic/Event'
8 | import Link from '@/logic/Link'
9 | import Room from '@/logic/Room'
10 | import Track from '@/logic/Track'
11 | import Type from '@/logic/Type'
12 | import Video from '@/logic/Video'
13 |
14 | import config from '@/config'
15 |
16 | const TIME_FORMAT = 'HH:mm'
17 | const STARTING_SOON_MINUTES = 60
18 | const STARTED_RECENTLY_MINUTES = 10
19 | const ENDING_SOON_MINUTES = 15
20 |
21 | const createDay = (date) => Object.freeze(new Day({
22 | date
23 | }))
24 |
25 | const createRoom = (room, building) => Object.freeze(new Room({
26 | name: room,
27 | building
28 | }))
29 |
30 | const createTrack = (name, type) => Object.freeze(new Track({
31 | name: name,
32 | type: type
33 | }))
34 |
35 | const createType = (type, priority) => {
36 | const conferenceType = priority < config.types.length ? config.types[priority] : config.types[config.types.length - 1]
37 |
38 | return Object.freeze(new Type({
39 | id: type.id,
40 | priority,
41 | name: type.name,
42 | statName: type.statName,
43 | ...conferenceType
44 | }))
45 | }
46 |
47 | const createEvent = (event, day, room, track, type) => {
48 | const links = event.links ? event.links.map(link => new Link(link)) : []
49 | const videos = event.videos ? event.videos.map(video => new Video(video)) : []
50 | // const videos = [new Video({ type: 'application/vnd.apple.mpegurl', url: 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8' })]
51 |
52 | return Object.freeze(new Event({
53 | id: event.id,
54 | startTime: config.features.localtimes ? moment.utc(event.startTime, TIME_FORMAT).add(-1, 'h').local().format(TIME_FORMAT) : event.startTime,
55 | duration: event.duration,
56 | title: event.title,
57 | subtitle: event.subtitle,
58 | abstract: event.abstract,
59 | description: event.description,
60 | language: event.language,
61 |
62 | type: type,
63 | track: track,
64 | day: day,
65 | room: room,
66 | persons: event.persons,
67 | links: links,
68 | videos,
69 | chat: event.chat
70 | }))
71 | }
72 |
73 | const eventNaturalSort = firstBy(event => event.day.index).thenBy('startTime').thenBy(event => event.type.priority).thenBy('endTime')
74 |
75 | const MAX_SEARCH_RESULTS = 50
76 |
77 | const scoreField = (field, multiplier, keywords) => keywords.filter(keyword => field.includes(keyword)).length * multiplier
78 |
79 | const scoreEvent = (event, favourites, keywords) => {
80 | let score = 0
81 |
82 | score += scoreField(event.title.toLowerCase(), 30, keywords)
83 |
84 | score += scoreField((event.subtitle || '').toLowerCase(), 20, keywords)
85 |
86 | score += scoreField(((event.abstract || '') + ' ' + event.track.name + ' ' + event.speakers).toLowerCase(), 10, keywords)
87 |
88 | if (favourites[event.id]) {
89 | score += 5
90 | }
91 |
92 | return score
93 | }
94 |
95 | const eventScoreSort = (eventScores) => firstBy(event => eventScores[event.id] || 0, -1).thenBy(eventNaturalSort)
96 |
97 | const eventLiveSort = (favourites) => firstBy(event => !favourites[event.id]).thenBy(eventNaturalSort)
98 |
99 | const trackSort = (favouriteTracks) => firstBy(track => !favouriteTracks[track.name]).thenBy(track => track.name.toLowerCase())
100 |
101 | const roomSort = firstBy(room => room.name.includes('online') ? 1 : -1).thenBy(room => room.name.toLowerCase())
102 |
103 | export default {
104 | state: {
105 | scheduleInitialized: false,
106 | scheduleUpdaterInitialized: false,
107 | lastModified: null,
108 | days: {},
109 | rooms: {},
110 | tracks: {},
111 | types: {},
112 | events: {},
113 | eventIndex: {}
114 | },
115 |
116 | getters: {
117 | scheduleInitialized: state => state.scheduleInitialized,
118 | lastModified: state => state.lastModified ? moment(state.lastModified).format('YYYY-MM-DD HH:mm') : null,
119 |
120 | days: state => state.days,
121 | rooms: state => state.rooms,
122 | tracks: state => state.tracks,
123 | types: state => state.types,
124 | events: state => state.events,
125 |
126 | allDays: state => Object.values(state.days).sort(firstBy('index')),
127 |
128 | allEvents: state => Object.values(state.events).sort(eventNaturalSort),
129 |
130 | type: state => typeName => Object.values(state.types)
131 | .find(type => type.name === typeName),
132 |
133 | typeEvents: state => typeName => Object.values(state.events)
134 | .filter(event => event.type.name === typeName)
135 | .sort(eventNaturalSort),
136 |
137 | trackEvents: state => trackName => Object.values(state.events)
138 | .filter(event => event.track.name === trackName)
139 | .sort(eventNaturalSort),
140 |
141 | roomEvents: state => roomName => Object.values(state.events)
142 | .filter(event => event.room.name === roomName)
143 | .sort(eventNaturalSort),
144 |
145 | favouriteEvents: (state, getters, rootState, rootGetters) => {
146 | const favourites = rootGetters.favourites
147 | return Object.values(state.events)
148 | .filter(event => favourites[event.id])
149 | .sort(eventNaturalSort)
150 | },
151 |
152 | favouriteAddedEvents: (state, getters, rootState, rootGetters) => oldFavouriteEvents => {
153 | const favourites = rootGetters.favourites
154 | const oldFavourites = {}
155 | oldFavouriteEvents.forEach(oldFavouriteEvent => {
156 | oldFavourites[oldFavouriteEvent.id] = true
157 | })
158 | return Object.values(state.events)
159 | .filter(event => favourites[event.id] || oldFavourites[event.id])
160 | .sort(eventNaturalSort)
161 | },
162 |
163 | selectedEvents: state => eventIds => {
164 | const eventIdSet = new Set(eventIds)
165 |
166 | return Object.values(state.events)
167 | .filter(event => eventIdSet.has(event.id))
168 | .sort(eventNaturalSort)
169 | },
170 |
171 | selectedEventsOrdered: state => eventIds => eventIds.map(eventId => state.events[eventId]).filter(Boolean),
172 |
173 | typeTrackStats: (state, getters, rootState, rootGetters) => typeName => {
174 | const typeEvents = getters.typeEvents(typeName)
175 | const eventsByDay = groupBy(Object.values(typeEvents), event => event.day.index)
176 | const favouriteTracks = rootGetters.favouriteTracks
177 |
178 | return getters.allDays.map(day => {
179 | const dayEvents = eventsByDay[day.index] || []
180 | const dayTracks = uniqBy(dayEvents.map(event => event.track), track => track.name).sort(trackSort(favouriteTracks))
181 | const tracks = dayTracks.map(track => {
182 | const events = dayEvents.filter(event => event.track.name === track.name).sort(eventNaturalSort)
183 |
184 | const rooms = uniqBy(events.map(event => event.room), room => room.name)
185 | rooms.sort(roomSort)
186 |
187 | return {
188 | track,
189 | rooms,
190 | events
191 | }
192 | })
193 |
194 | return {
195 | day,
196 | tracks
197 | }
198 | })
199 | },
200 |
201 | allTypeStats: state => {
202 | const types = Object.values(state.types).sort(firstBy('priority'))
203 | const eventsByType = groupBy(Object.values(state.events), event => event.type.name)
204 | return types.map(type => {
205 | const events = eventsByType[type.name] || []
206 | const tracks = uniqBy(events.sort(eventNaturalSort).map(event => event.track), track => track.name)
207 |
208 | return {
209 | type,
210 | events,
211 | tracks
212 | }
213 | })
214 | },
215 |
216 | liveEvents: (state, getters, rootState, rootGetters) => {
217 | const currentDate = rootGetters.currentDate
218 | const currentTime = rootGetters.currentTime
219 |
220 | const minEndTime = moment(currentTime, TIME_FORMAT).add(ENDING_SOON_MINUTES, 'minutes').format(TIME_FORMAT)
221 | const minStartTime = moment(currentTime, TIME_FORMAT).subtract(STARTED_RECENTLY_MINUTES, 'minutes').format(TIME_FORMAT)
222 | const maxStartTime = moment(currentTime, TIME_FORMAT).add(STARTING_SOON_MINUTES, 'minutes').format(TIME_FORMAT)
223 |
224 | const favourites = rootGetters.favourites
225 | const events = Object.values(state.events)
226 | .filter(event => event.happeningLive(!!favourites[event.id], currentDate, currentTime, minEndTime, minStartTime, maxStartTime))
227 | .sort(eventLiveSort(favourites))
228 | return events
229 | },
230 |
231 | liveTrackEvent: (state, getters, rootState, rootGetters) => trackName => {
232 | if (!trackName) {
233 | return null
234 | }
235 | const currentDate = rootGetters.currentDate
236 | const currentTime = rootGetters.currentTime
237 | const event = getters.trackEvents(trackName)
238 | .find(event => event.happeningNow(currentDate, currentTime))
239 | return event || null
240 | },
241 |
242 | nextTrackEvent: (state, getters) => event => {
243 | if (!event) {
244 | return null
245 | }
246 | const trackEvents = getters.trackEvents(event.track.name)
247 | const index = trackEvents.findIndex(e => e.id === event.id)
248 | return trackEvents[index + 1] || null
249 | },
250 |
251 | previousTrackEvent: (state, getters) => event => {
252 | if (!event) {
253 | return null
254 | }
255 | const trackEvents = getters.trackEvents(event.track.name)
256 | const index = trackEvents.findIndex(e => e.id === event.id)
257 | return trackEvents[index - 1] || null
258 | },
259 |
260 | searchEvents: (state, getters, rootState, rootGetters) => query => {
261 | const keywords = query.toLowerCase().split(' ')
262 | let foundEvents = []
263 |
264 | for (const [eventId, blob] of Object.entries(state.eventIndex)) {
265 | if (keywords.every(keyword => blob.includes(keyword))) {
266 | foundEvents.push(state.events[eventId])
267 | }
268 | }
269 |
270 | const eventScores = {}
271 | foundEvents.forEach(event => {
272 | eventScores[event.id] = scoreEvent(event, rootGetters.favourites, keywords)
273 | })
274 |
275 | foundEvents = foundEvents.sort(eventScoreSort(eventScores))
276 |
277 | foundEvents = foundEvents.splice(0, MAX_SEARCH_RESULTS)
278 |
279 | return foundEvents
280 | }
281 | },
282 |
283 | mutations: {
284 | setScheduleInitialized (state, initialized) {
285 | state.scheduleInitialized = initialized
286 | },
287 |
288 | setLastModified (state, lastModified) {
289 | state.lastModified = lastModified
290 | },
291 |
292 | setDays (state, days) {
293 | state.days = days
294 | },
295 |
296 | setRooms (state, rooms) {
297 | state.rooms = rooms
298 | },
299 |
300 | setTracks (state, tracks) {
301 | state.tracks = tracks
302 | },
303 |
304 | setTypes (state, types) {
305 | state.types = types
306 | },
307 |
308 | setEvents (state, events) {
309 | state.events = events
310 | },
311 |
312 | setEventIndex (state, eventIndex) {
313 | state.eventIndex = eventIndex
314 | },
315 |
316 | initializeScheduleUpdater (state) {
317 | state.scheduleUpdaterInitialized = true
318 | }
319 | },
320 |
321 | actions: {
322 | async initSchedule ({ state, commit, getters, dispatch, rootGetters }, cache) {
323 | if (!cache) {
324 | cache = 'default'
325 | }
326 |
327 | const response = await fetch(rootGetters.conferenceScheduleUrl, { cache })
328 |
329 | if (!response.ok) {
330 | throw new Error(`${response.status}: ${response.statusText}`)
331 | }
332 |
333 | const lastModified = response.headers.get('Last-Modified')
334 | if (state.lastModified && state.lastModified === lastModified) {
335 | return
336 | }
337 |
338 | commit('setLastModified', lastModified)
339 |
340 | const conference = await response.json()
341 |
342 | if (!conference.events) {
343 | return
344 | }
345 |
346 | const days = {}
347 | const events = {}
348 | const rooms = {}
349 | const types = {}
350 | const tracks = {}
351 |
352 | const typeList = conference.types.map((t, index) => createType(t, index))
353 | typeList.forEach((t) => {
354 | types[t.id] = t
355 | })
356 |
357 | const dateCache = {}
358 |
359 | conference.events.forEach(e => {
360 | let day = dateCache[e.date]
361 | if (!day) {
362 | day = createDay(e.date)
363 | days[day.index] = day
364 | dateCache[e.date] = day
365 | }
366 |
367 | // TODO: make buildings universal
368 | const building = getters.roomBuilding(e.room)
369 |
370 | let room = rooms[e.room]
371 | if (!room) {
372 | room = createRoom(e.room, building)
373 | rooms[room.name] = room
374 | }
375 |
376 | const type = types[e.type]
377 | if (!type) {
378 | throw new Error(`Unknown type ${e.type}`)
379 | }
380 |
381 | let track = tracks[e.track]
382 | if (!track) {
383 | track = createTrack(e.track, type)
384 | tracks[track.name] = track
385 | }
386 |
387 | const event = createEvent(e, day, room, track, type)
388 | events[event.id] = event
389 | })
390 |
391 | commit('setDays', days)
392 | commit('setRooms', rooms)
393 | commit('setTracks', tracks)
394 | commit('setTypes', types)
395 | commit('setEvents', events)
396 | commit('setScheduleInitialized', true)
397 |
398 | await dispatch('reindexEvents')
399 | },
400 |
401 | refreshSchedule ({ dispatch }) {
402 | if (!navigator.onLine) {
403 | return Promise.reject(new Error('Offline'))
404 | }
405 |
406 | return dispatch('initSchedule', 'reload')
407 | },
408 |
409 | async initScheduleBuster ({ dispatch, state }) {
410 | await new Promise((resolve) => setTimeout(resolve, 5000))
411 |
412 | if (state.lastModified && (new Date(state.lastModified) < new Date('2025-01-19 20:00 GMT'))) {
413 | console.log('Reloading stale schedule')
414 | dispatch('refreshSchedule')
415 | }
416 | },
417 |
418 | notifyRefreshSchedule ({ dispatch }) {
419 | dispatch('initSchedule')
420 | },
421 |
422 | initScheduleUpdater ({ dispatch, state, commit }) {
423 | if (!process.env.SCHEDULE_INTERVAL || state.scheduleUpdaterInitialized) {
424 | return
425 | }
426 | const pollInterval = parseInt(process.env.SCHEDULE_INTERVAL)
427 |
428 | setInterval(() => dispatch('initSchedule'), pollInterval * 1000)
429 |
430 | commit('initializeScheduleUpdater')
431 | },
432 |
433 | reindexEvents ({ state, getters, commit, dispatch }) {
434 | const index = {}
435 | for (const event of Object.values(state.events)) {
436 | const blob = JSON.stringify(event, null, 2).toLowerCase()
437 | .normalize('NFD').replace(/[\u0300-\u036f]/g, '')
438 | .replace(/"[a-zA-Z0-9_]+":|/g, '').replace(/",|"|/g, '')
439 | index[event.id] = blob
440 | }
441 | commit('setEventIndex', index)
442 |
443 | getters.searchEvents('warm')
444 | }
445 | }
446 | }
447 |
--------------------------------------------------------------------------------