├── 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 | 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 | 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 | 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 | 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 | 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 | 11 | 12 | 36 | 37 | 39 | -------------------------------------------------------------------------------- /components/Play.vue: -------------------------------------------------------------------------------- 1 | 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 | 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 | 7 | 8 | 38 | 39 | 48 | -------------------------------------------------------------------------------- /components/TrackListPlain.vue: -------------------------------------------------------------------------------- 1 | 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 | 7 | 8 | 39 | 40 | 54 | -------------------------------------------------------------------------------- /pages/ConferenceTrackEvents.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 52 | 53 | 55 | -------------------------------------------------------------------------------- /pages/Delete.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 34 | 35 | 54 | -------------------------------------------------------------------------------- /pages/FavouriteEvents.vue: -------------------------------------------------------------------------------- 1 | 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 | 20 | 21 | 44 | 45 | 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sojourner Web 2 | 3 | [![Netlify Status](https://api.netlify.com/api/v1/badges/e729e002-d94e-4404-ae14-dfe88858fe1c/deploy-status)](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 | 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 | 6 | 7 | 27 | 28 | 30 | 31 | 63 | -------------------------------------------------------------------------------- /pages/TypeTracksOrEvents.vue: -------------------------------------------------------------------------------- 1 | 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 | 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 | 24 | 25 | 53 | 54 | 69 | -------------------------------------------------------------------------------- /components/BottomMenu.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 55 | 56 | 66 | -------------------------------------------------------------------------------- /components/TrackList.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 87 | -------------------------------------------------------------------------------- /components/Notification.vue: -------------------------------------------------------------------------------- 1 | 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 | 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 |
34 |
35 |
36 |
37 |
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 | 40 | 41 | 77 | 78 | 101 | -------------------------------------------------------------------------------- /pages/CampusMap.vue: -------------------------------------------------------------------------------- 1 | 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 | 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 | 26 | 27 | 114 | 115 | 142 | -------------------------------------------------------------------------------- /pages/About.vue: -------------------------------------------------------------------------------- 1 | 54 | 55 | 87 | 88 | 118 | -------------------------------------------------------------------------------- /pages/Dashboard.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 59 | 60 | 113 | 114 | 135 | -------------------------------------------------------------------------------- /components/MainMenu.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 93 | 94 | 122 | -------------------------------------------------------------------------------- /components/Player.vue: -------------------------------------------------------------------------------- 1 | 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\n

We, 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\n

Ideally, 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\n

The 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\n

In 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 | 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 | 93 | 94 | 158 | 159 | 228 | -------------------------------------------------------------------------------- /components/LoginDialog.vue: -------------------------------------------------------------------------------- 1 | 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 | 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 | --------------------------------------------------------------------------------