├── .babelrc ├── .gitattributes ├── .gitignore ├── LICENSE ├── README.md ├── compilePug.js ├── package-lock.json ├── package.json ├── src ├── actions.js ├── app.pug.js ├── css │ └── style.css ├── default.html ├── effects.js ├── handleRequests.js ├── img │ └── icons │ │ ├── favicon.ico │ │ ├── icon-1024x1024.png │ │ └── icon-192x192.png ├── index.html ├── main.js ├── manifest.webmanifest ├── pug-to-view.js ├── pug-vdom.js ├── serviceWorkerHandler.js ├── subscriptions.js ├── sw.js └── utils.js └── views └── app.pug /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [["@babel/preset-env", { 3 | "targets": { 4 | "node": "current" 5 | } 6 | }]], 7 | "plugins": ["@babel/plugin-transform-runtime"], 8 | "ignore": ["integration"] 9 | } 10 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Optional REPL history 57 | .node_repl_history 58 | 59 | # Output of 'npm pack' 60 | *.tgz 61 | 62 | # Yarn Integrity file 63 | .yarn-integrity 64 | 65 | # dotenv environment variables file 66 | .env 67 | .env.test 68 | 69 | # parcel-bundler cache (https://parceljs.org/) 70 | .cache 71 | 72 | # next.js build output 73 | .next 74 | 75 | # nuxt.js build output 76 | .nuxt 77 | 78 | # vuepress build output 79 | .vuepress/dist 80 | 81 | # Serverless directories 82 | .serverless/ 83 | 84 | # FuseBox cache 85 | .fusebox/ 86 | 87 | # DynamoDB Local files 88 | .dynamodb/ 89 | 90 | dist/ 91 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 johnkazer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hyperapp-demo 2 | A [Hyperapp](https://hyperapp.dev/) demo, which implements a simple multi-media [PWA](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps) app with [XState](https://xstate.js.org). You can take a photo, create a recording and simulate uploading them to the cloud. If offline, the PWA will save locally and automatically 'upload' when you're back online. 3 | 4 | The master branch just uses Hyperapp, whilst the xstate branch modifies the app to use XState state machines. 5 | 6 | Note that unlike most Hyperapp implementations, this app uses Pug to render HTML (and also a temporarily local version of pug-vdom to support textNodes in Hyperapp). 7 | 8 | ## Why? 9 | 10 | Use (simple) maths not dev process to create reliable web apps. 11 | 12 | De-stress app development. 13 | 14 | Not only will you _know_ that your app's pure functions work (existing testing) but you will also _know_ that the business logic which wires them together also works! 15 | 16 | You can use easily accessible mathematical principles to build reliable apps rather than software process. 17 | 18 | Using an XState machine means that you can pre-define how sequences of user actions and events lead to state change and therefore app behaviour. The logic is clear and easy to reason about (rather than obscured in collections of functions or separate hard-to-maintain flow diagrams). You can even [visualise](https://statecharts.github.io/xstate-viz/) the logic with interactive state machine charts and create [tests](https://glebbahmutov.com/blog/hyperapp-state-machine/#testing-from-state-machine) easily. 19 | 20 | ## How it works - Hyperapp 21 | 22 | [Hyperapp](https://hyperapp.dev/) maintains a central state and message handler which listens for user actions and browser events. When an action or event changes the state, Hyperapp uses a virtual-DOM to update the app. A loop exists, half visible to the developer and half within the framework. 23 | 24 | Action -> Event -> [ Listener -> State management -> Virtual-DOM ] -> DOM-change -> Action... 25 | 26 | ## How it works - XState 27 | 28 | State machines are a long-standing mathematical tool. Their practical application to apps has some common practical and conceptual features with how Hyperapp defines app behaviour. The main difference is that [XState](https://xstate.js.org) enables the relationships between Actions, Events and State to be defined unambiguously in one place. 29 | 30 | A state machine is created in JSON, whilst the XState system provides pure functions for interacting with it. Your app can respond to action and event-driven change or request state changes directly. 31 | 32 | Action -> [ State change -> Possible Actions ] -> Action... 33 | 34 | There are two state machines in the demo, one to manage taking photo's and the other for recordings. 35 | 36 | ## What's next? 37 | 38 | Other aspects of the app may be suitable for state machines, including upload, online/offline state and PWA installation. There is an interesting boundary of responsibility between Hyperapp and XState that this demo has only just started to explore. 39 | 40 | Where and how should state be managed? 41 | 42 | What role components? 43 | 44 | How many machines do you need? 45 | -------------------------------------------------------------------------------- /compilePug.js: -------------------------------------------------------------------------------- 1 | const vDom = require('./src/pug-vdom') 2 | vDom.generateFile('./views/app.pug', './src/app.pug.js', './views') 3 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hyperapp-xstate-demo", 3 | "version": "0.0.2", 4 | "description": "A hyperapp demo using xstate and PWA", 5 | "main": "main.js", 6 | "scripts": { 7 | "start": "node ./compilePug.js && cpx ./src/sw.js ./dist && cpx ./src/default.html ./dist && parcel ./src/index.html" 8 | }, 9 | "author": "John Kazer", 10 | "license": "ISC", 11 | "devDependencies": { 12 | "@babel/core": "^7.4.5", 13 | "@babel/plugin-transform-runtime": "^7.4.4", 14 | "cpx": "^1.5.0", 15 | "parcel": "^1.12.4", 16 | "virtual-dom": "^2.1.1" 17 | }, 18 | "dependencies": { 19 | "@babel/runtime": "^7.4.5", 20 | "hyperapp": "2.0.12", 21 | "idb-keyval": "^3.2.0", 22 | "pug": "^2.0.4", 23 | "pug-vdom": "^1.1.2", 24 | "ramda": "^0.26.1" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/actions.js: -------------------------------------------------------------------------------- 1 | import * as effects from './effects.js' 2 | import { concat, lensPath, set, pipe, pathEq, filter, curry, map, pathOr, view } from 'ramda' 3 | import * as U from './utils.js' 4 | // Don't curry actions passed to effects! It feels like you need to in order to grab `state` when passing an action, but this is what the `dispatch()` function will do 5 | 6 | const HTTP_REQUESTS = effects.HTTP_REQUESTS 7 | const AUDIO_STATE = effects.AUDIO_STATE 8 | const IMAGE_STATE = effects.IMAGE_STATE 9 | 10 | const videoUseByLens = lensPath([0, 'usedBy']) 11 | const audioUseByLens = lensPath([1, 'usedBy']) 12 | const currentTabLens = lensPath(['active']) 13 | 14 | const manageUpload = (state, status, images, recordings) => { 15 | if (status === 'offline') { // save file to local storage 16 | const onlineStatusMsg = 'App is offline' 17 | return [ 18 | resetAfterSaveUploadMedia({ ...state, status, onlineStatusMsg }), 19 | effects.addToLocalStoreFx(images, recordings) 20 | ] 21 | } else { 22 | const onlineStatusMsg = 'App is online' 23 | const uploadingStatusMsg = 'Uploading files(s), please wait ...' 24 | return [ 25 | { ...state, status, uploadingStatusMsg, onlineStatusMsg }, 26 | effects.uploadFx(httpSuccessMsg, httpFailMsg, concat(images, recordings), '') 27 | ] 28 | } 29 | } 30 | const installAsPwa = (state) => [ 31 | state, 32 | effects.installAsPwaFx(state.deferredPrompt) 33 | ] 34 | const pwaResponseHandler = (state, { outcome }) => { 35 | const installed = outcome === 'accepted' ? true : false 36 | return { ...state, installed } 37 | } 38 | export const handleInstallState = (state, { deferredPrompt, installed }) => { 39 | return { ...state, deferredPrompt, installed } 40 | } 41 | const processImage = (state, { tabStatus, images }) => { 42 | const buttons = U.resetButtonState(state.buttons, tabStatus) 43 | return { ...state, images, buttons } 44 | } 45 | const captureImage = (state, event) => { 46 | const uploadingStatusMsg = 'Not uploading' 47 | const tabs = set(videoUseByLens, IMAGE_STATE.TAKEN, state.tabs) 48 | return [ 49 | { 50 | ...state, uploadingStatusMsg, tabs 51 | }, 52 | effects.takePictureFx(processImage) 53 | ] 54 | } 55 | const discardImage = (state, event) => { 56 | const tabs = set(videoUseByLens, IMAGE_STATE.INIT, state.tabs) 57 | return U.resetImage({ ...state, tabs }, IMAGE_STATE.INIT) 58 | } 59 | const resetAfterSaveUploadMedia = (state) => { 60 | const recordingStatusMsg = 'Not Recording' 61 | const uploadingStatusMsg = 'Successfully saved' 62 | const recordings = [] 63 | const audioUrl = [] 64 | const images = [] 65 | const resetAudioTab = set(audioUseByLens, AUDIO_STATE.INIT) 66 | const resetVideoTab = set(videoUseByLens, IMAGE_STATE.INIT) 67 | const tabs = pipe( 68 | resetAudioTab, 69 | resetVideoTab 70 | )(state.tabs) 71 | const viewCurrentTab = (tab) => view(currentTabLens)(tab) 72 | const currentTab = filter(viewCurrentTab, tabs)[0] 73 | const buttons = U.resetButtonState(state.buttons, currentTab.usedBy) 74 | return { ...state, uploadingStatusMsg, buttons, recordings, images, audioUrl, recordingStatusMsg, tabs } 75 | } 76 | const httpSuccessMsg = (state, { request }) => { 77 | switch (request) { 78 | case effects.HTTP_REQUESTS.UPLOAD_FILES: { 79 | return [ 80 | resetAfterSaveUploadMedia(state), 81 | effects.removeFromLocalStoreFx() 82 | ] 83 | } 84 | } 85 | const tabs = selectTab(state, 'videoTab') 86 | return { ...state, tabs } 87 | } 88 | const httpFailMsg = (state, { request, error }) => { 89 | switch (request) { 90 | case HTTP_REQUESTS.UPLOAD_FILES: { 91 | const uploadingStatusMsg = 'Upload failed... ' + error.status + ': ' + error.msg 92 | return { ...state, uploadingStatusMsg } 93 | } 94 | } 95 | const tabs = selectTab(state, 'videoTab') 96 | return { ...state, tabs } 97 | } 98 | export const updateStatus = (state, data) => { 99 | const { status } = data 100 | const images = pathOr(state.images, ['images'], data) 101 | const recordings = pathOr(state.images, ['recordings'], data) 102 | return manageUpload(state, status, images, recordings) 103 | } 104 | const selectTab = (state, event) => { 105 | const id = event.target.id 106 | const updateTabStatus = curry((id, tab) => { 107 | const active = tab.id === id 108 | return { ...tab, active } 109 | })(id) 110 | const tabs = map(updateTabStatus, state.tabs) 111 | const buttons = U.resetButtonState(state.buttons, tabs.find((element) => element.id === id).usedBy) 112 | return { ...state, tabs, buttons } 113 | } 114 | const uploadFiles = (state, event) => { 115 | const { status, images, recordings } = state 116 | return manageUpload(state, status, images, recordings) 117 | } 118 | const deleteAudio = (state, event) => { 119 | const tabs = set(audioUseByLens, AUDIO_STATE.INIT, state.tabs) 120 | return U.resetAudio({ ...state, tabs }, AUDIO_STATE.INIT) 121 | } 122 | export const audioReady = (state, { status, url, recordings }) => { 123 | if (status === AUDIO_STATE.READY) { 124 | const recordingStatusMsg = 'Recording ready' 125 | const audioUrl = [url] 126 | const buttons = U.resetButtonState(state.buttons, status) 127 | return { ...state, recordings, buttons, audioUrl, recordingStatusMsg } 128 | } else { 129 | return U.resetAudio(state, AUDIO_STATE.INIT) 130 | } 131 | } 132 | const stopAudio = (state, event) => { 133 | const tabs = set(audioUseByLens, AUDIO_STATE.READY, state.tabs) 134 | return [ 135 | { 136 | ...state, tabs 137 | }, 138 | effects.stopRecordingFx(audioReady) 139 | ] 140 | } 141 | const recordingStarted = (state, response) => { 142 | if (response.status === AUDIO_STATE.RECORDING) { 143 | const recordingStatusMsg = 'Recording...' 144 | const buttons = U.resetButtonState(state.buttons, AUDIO_STATE.RECORDING) 145 | return { ...state, recordingStatusMsg, buttons } 146 | } else { 147 | return U.resetAudio(state, AUDIO_STATE.INIT) 148 | } 149 | } 150 | const recordAudio = (state, event) => [ 151 | state, 152 | effects.startRecordingFx(recordingStarted) 153 | ] 154 | 155 | export const initialStateObj = { 156 | 'title': 'Hyperapp demo', // pug-vdom expects at least a 'title' by default 157 | 'status': 'online', 158 | 'deferredPrompt': null, // PWA status variable 159 | 'onlineStatusMsg': 'App is online', 160 | 'uploadingStatusMsg': 'Not uploading', 161 | 'accountInfoStatusMsg': 'No requests submitted', 162 | 'recordingStatusMsg': 'Not recording', 163 | 'images': [], 164 | 'recordings': [], 165 | 'audioUrl': [], 166 | 'buttons': [ 167 | { 'id': 'uploadImage', 'active': false, 'action': uploadFiles, 'txt': 'Upload Photo', 'usedBy': IMAGE_STATE.TAKEN }, 168 | { 'id': 'discardImage', 'active': false, 'action': discardImage, 'txt': 'Delete Photo', 'usedBy': IMAGE_STATE.TAKEN }, 169 | { 'id': 'captureImage', 'active': true, 'action': captureImage, 'txt': 'Take Picture', 'usedBy': IMAGE_STATE.INIT }, 170 | { 'id': 'uploadAudio', 'active': false, 'action': uploadFiles, 'txt': 'Upload Recording', 'usedBy': AUDIO_STATE.READY }, 171 | { 'id': 'deleteAudio', 'active': false, 'action': deleteAudio, 'txt': 'Delete Recording', 'usedBy': AUDIO_STATE.READY }, 172 | { 'id': 'stopAudio', 'active': false, 'action': stopAudio, 'txt': 'Stop', 'usedBy': AUDIO_STATE.RECORDING }, 173 | { 'id': 'recordAudio', 'active': false, 'action': recordAudio, 'txt': 'Start Recording', 'usedBy': AUDIO_STATE.INIT }, 174 | ], 175 | 'tabs': [ 176 | { 'id': 'videoTab', 'active': true, 'action': selectTab, 'tabName': 'videoSelection', 'txt': 'Take a Picture', 'usedBy': IMAGE_STATE.INIT }, 177 | { 'id': 'audioTab', 'active': false, 'action': selectTab, 'tabName': 'audioSelection', 'txt': 'Make a Recording', 'usedBy': AUDIO_STATE.INIT } 178 | ], 179 | 'installAsPwa': installAsPwa, 180 | 'installed': true 181 | } 182 | -------------------------------------------------------------------------------- /src/app.pug.js: -------------------------------------------------------------------------------- 1 | // PUG VDOM generated file 2 | function render(context, h, text = (string) => string) { 3 | if (!pugVDOMRuntime) throw "pug-vdom runtime not found."; 4 | var runtime = pugVDOMRuntime 5 | var locals = context; 6 | var self = locals; 7 | var remainingKeys = pugVDOMRuntime.exposeLocals(locals); 8 | for (var prop in remainingKeys) { 9 | eval('var ' + prop + ' = locals.' + prop); 10 | } 11 | var n0Child = [] 12 | var n1Child = [] 13 | var n2Child = [] 14 | n2Child = n2Child.concat(text(title)) 15 | var props = {attributes: runtime.compileAttrs([], [])}; 16 | if (props.attributes.id) props.key = props.attributes.id; 17 | var n2 = h('h1', props, n2Child) 18 | n1Child.push(n2) 19 | if(!installed) { 20 | var n3Child = [] 21 | n3Child.push(text("Install")) 22 | var props = {attributes: runtime.compileAttrs([{name:'class', val: 'btn'},{name:'onclick', val: installAsPwa}], [])}; 23 | if (props.attributes.id) props.key = props.attributes.id; 24 | var n3 = h('button', props, n3Child) 25 | n1Child.push(n3) 26 | } 27 | var n4Child = [] 28 | n4Child = n4Child.concat(text(onlineStatusMsg)) 29 | var props = {attributes: runtime.compileAttrs([{name:'class', val: status}], [])}; 30 | if (props.attributes.id) props.key = props.attributes.id; 31 | var n4 = h('p', props, n4Child) 32 | n1Child.push(n4) 33 | var n5Child = [] 34 | n5Child = n5Child.concat(text(uploadingStatusMsg)) 35 | var props = {attributes: runtime.compileAttrs([], [])}; 36 | if (props.attributes.id) props.key = props.attributes.id; 37 | var n5 = h('p', props, n5Child) 38 | n1Child.push(n5) 39 | var showTab 40 | var n6Child = [] 41 | var v7 = tabs 42 | Object.keys(v7).forEach(function (k8) { 43 | var button = v7[k8] 44 | var n9Child = [] 45 | n9Child = n9Child.concat(text(button.txt)) 46 | var props = {attributes: runtime.compileAttrs([{name:'class', val: 'tabLinks'},{name:'id', val: button.id},{name:'onclick', val: button.action}], [])}; 47 | if (props.attributes.id) props.key = props.attributes.id; 48 | var n9 = h('button', props, n9Child) 49 | n6Child.push(n9) 50 | if((button.active)) { 51 | showTab = button.tabName 52 | } 53 | }.bind(this)) 54 | var props = {attributes: runtime.compileAttrs([{name:'class', val: 'tab'},{name:'class', val: 'align-centre'}], [])}; 55 | if (props.attributes.id) props.key = props.attributes.id; 56 | var n6 = h('div', props, n6Child) 57 | n1Child.push(n6) 58 | var n10Child = [] 59 | var n11Child = [] 60 | var n12Child = [] 61 | var props = {attributes: runtime.compileAttrs([{name:'autoPlay', val: true},{name:'playsInline', val: true},{name:'muted', val: true},{name:'id', val: "webcam"},{name:'width', val: "100%"},{name:'height', val: "200"}], [])}; 62 | if (props.attributes.id) props.key = props.attributes.id; 63 | var n12 = h('video', props, n12Child) 64 | n11Child.push(n12) 65 | var n13Child = [] 66 | var v14 = images 67 | Object.keys(v14).forEach(function (k15) { 68 | var img = v14[k15] 69 | var n16Child = [] 70 | var props = {attributes: runtime.compileAttrs([{name:'src', val: img},{name:'alt', val: "captured"},{name:'height', val: "200"}], [])}; 71 | if (props.attributes.id) props.key = props.attributes.id; 72 | var n16 = h('img', props, n16Child) 73 | n13Child.push(n16) 74 | }.bind(this)) 75 | var props = {attributes: runtime.compileAttrs([{name:'id', val: 'imageCanvas'},{name:'class', val: 'imageCanvas'}], [])}; 76 | if (props.attributes.id) props.key = props.attributes.id; 77 | var n13 = h('div', props, n13Child) 78 | n11Child.push(n13) 79 | var n17Child = [] 80 | var props = {attributes: runtime.compileAttrs([], [])}; 81 | if (props.attributes.id) props.key = props.attributes.id; 82 | var n17 = h('br', props, n17Child) 83 | n11Child.push(n17) 84 | var props = {attributes: runtime.compileAttrs([{name:'class', val: 'align-centre'}], [])}; 85 | if (props.attributes.id) props.key = props.attributes.id; 86 | var n11 = h('div', props, n11Child) 87 | n10Child.push(n11) 88 | var n18Child = [] 89 | var v19 = buttons 90 | Object.keys(v19).forEach(function (k20) { 91 | var button = v19[k20] 92 | var display = button.active ? 'block' : 'none' 93 | var n21Child = [] 94 | n21Child = n21Child.concat(text(button.txt)) 95 | var props = {attributes: runtime.compileAttrs([{name:'class', val: 'btn'},{name:'class', val: 'btn-primary'},{name:'id', val: button.id},{name:'onclick', val: button.action},{name:'style', val: {display: display}}], [])}; 96 | if (props.attributes.id) props.key = props.attributes.id; 97 | var n21 = h('button', props, n21Child) 98 | n18Child.push(n21) 99 | }.bind(this)) 100 | var props = {attributes: runtime.compileAttrs([{name:'class', val: 'align-centre'}], [])}; 101 | if (props.attributes.id) props.key = props.attributes.id; 102 | var n18 = h('div', props, n18Child) 103 | n10Child.push(n18) 104 | var props = {attributes: runtime.compileAttrs([{name:'class', val: 'tabContent'},{name:'class', val: 'surround'},{name:'id', val: 'videoSelection'},{name:'style', val: showTab === 'videoSelection' ? {display: 'block'} : {display: 'none'}}], [])}; 105 | if (props.attributes.id) props.key = props.attributes.id; 106 | var n10 = h('div', props, n10Child) 107 | n1Child.push(n10) 108 | var n22Child = [] 109 | var n23Child = [] 110 | n23Child = n23Child.concat(text(recordingStatusMsg)) 111 | var props = {attributes: runtime.compileAttrs([], [])}; 112 | if (props.attributes.id) props.key = props.attributes.id; 113 | var n23 = h('p', props, n23Child) 114 | n22Child.push(n23) 115 | var n24Child = [] 116 | var v25 = buttons 117 | Object.keys(v25).forEach(function (k26) { 118 | var button = v25[k26] 119 | var display = button.active ? 'block' : 'none' 120 | var n27Child = [] 121 | n27Child = n27Child.concat(text(button.txt)) 122 | var props = {attributes: runtime.compileAttrs([{name:'class', val: 'btn'},{name:'class', val: 'btn-primary'},{name:'id', val: button.id},{name:'onclick', val: button.action},{name:'style', val: {display: display}}], [])}; 123 | if (props.attributes.id) props.key = props.attributes.id; 124 | var n27 = h('button', props, n27Child) 125 | n24Child.push(n27) 126 | }.bind(this)) 127 | var props = {attributes: runtime.compileAttrs([{name:'class', val: 'align-centre'}], [])}; 128 | if (props.attributes.id) props.key = props.attributes.id; 129 | var n24 = h('div', props, n24Child) 130 | n22Child.push(n24) 131 | if(audioUrl.length) { 132 | var v28 = audioUrl 133 | Object.keys(v28).forEach(function (k29) { 134 | var url = v28[k29] 135 | var n30Child = [] 136 | var props = {attributes: runtime.compileAttrs([{name:'src', val: url},{name:'controls', val: 'controls'}], [])}; 137 | if (props.attributes.id) props.key = props.attributes.id; 138 | var n30 = h('audio', props, n30Child) 139 | n22Child.push(n30) 140 | var n31Child = [] 141 | var props = {attributes: runtime.compileAttrs([{name:'href', val: url}], [])}; 142 | if (props.attributes.id) props.key = props.attributes.id; 143 | var n31 = h('a', props, n31Child) 144 | n22Child.push(n31) 145 | }.bind(this)) 146 | } 147 | var props = {attributes: runtime.compileAttrs([{name:'class', val: 'tabContent'},{name:'class', val: 'surround'},{name:'id', val: 'audioSelection'},{name:'style', val: showTab === 'audioSelection' ? {display: 'block'} : {display: 'none'}}], [])}; 148 | if (props.attributes.id) props.key = props.attributes.id; 149 | var n22 = h('div', props, n22Child) 150 | n1Child.push(n22) 151 | var props = {attributes: runtime.compileAttrs([], [])}; 152 | if (props.attributes.id) props.key = props.attributes.id; 153 | var n1 = h('div', props, n1Child) 154 | n0Child.push(n1) 155 | pugVDOMRuntime.deleteExposedLocals() 156 | return n0Child 157 | } 158 | 159 | module.exports = render 160 | -------------------------------------------------------------------------------- /src/css/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding: 5px; 3 | } 4 | 5 | a { 6 | color: #00B7FF; 7 | } 8 | 9 | .online { 10 | color: black; 11 | } 12 | 13 | .offline { 14 | color: red; 15 | } 16 | .align-centre { 17 | display: flex; 18 | flex: 1; 19 | align-items: center; 20 | justify-content: center; 21 | } 22 | .surround { 23 | border-style: solid; 24 | border-color: black; 25 | border-width: 2px; 26 | } 27 | -------------------------------------------------------------------------------- /src/default.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Hyperapp demo 6 | 7 | 8 |

Hi, sorry the app is not working. Refresh or check your data connection.

9 | 10 | 11 | -------------------------------------------------------------------------------- /src/effects.js: -------------------------------------------------------------------------------- 1 | import * as requestHandler from './handleRequests.js' 2 | import { map } from 'ramda' 3 | 4 | export const AUDIO_STATE = { 5 | RECORDING: 'AUDIO_RECORDING', 6 | INIT: 'AUDIO_INIT', 7 | STOPPED: 'AUDIO_STOPPED', 8 | READY: 'AUDIO_READY' 9 | } 10 | export const IMAGE_STATE = { 11 | INIT: 'IMAGE_INIT', 12 | TAKEN: 'IMAGE_TAKEN' 13 | } 14 | export const HTTP_REQUESTS = { 15 | UPLOAD_FILES: 'UPLOAD_FILES' 16 | } 17 | async function installAsPwa(dispatch, { deferredPrompt, pwaResponseHandler }) { 18 | if (deferredPrompt) { 19 | deferredPrompt.prompt(); 20 | deferredPrompt.userChoice.then(function(choiceResult){ 21 | if (choiceResult.outcome === 'accepted') { 22 | console.log('Your PWA has been installed'); 23 | dispatch(pwaResponseHandler, { outcome: 'accepted' }) 24 | } else { 25 | console.log('User chose to not install your PWA'); 26 | dispatch(pwaResponseHandler, { outcome: 'rejected' }) 27 | } 28 | deferredPrompt = null; 29 | }); 30 | } 31 | } 32 | export const installAsPwaFx = (deferredPrompt, pwaResponseHandler) => [ 33 | installAsPwa, 34 | { 35 | deferredPrompt, 36 | pwaResponseHandler 37 | } 38 | ] 39 | function processExternalRequest (dispatch, command) { 40 | const { request, files, success, fail, ACCESS_TOKEN } = command 41 | const submitFile = (rawFile) => { 42 | // not yet impl 43 | // just move to next state or file 44 | // Impl upload authorisation, such as Dropbox 45 | // https://github.com/dropbox/dropbox-sdk-js/blob/master/examples/javascript/auth/index.html 46 | // and upload 47 | // https://github.com/dropbox/dropbox-sdk-js/blob/master/examples/javascript/upload/index.html 48 | return dispatch(success, { request }) 49 | } 50 | 51 | switch (request) { 52 | case HTTP_REQUESTS.UPLOAD_FILES: { 53 | if (!Array.isArray(files)) return 54 | return map(submitFile, files) 55 | } 56 | } 57 | } 58 | const upload = (dispatch, { success, fail, files, ACCESS_TOKEN }) => { 59 | processExternalRequest(dispatch, { request: HTTP_REQUESTS.UPLOAD_FILES, success, fail, files, ACCESS_TOKEN }) 60 | } 61 | export const uploadFx = (httpSuccess, httpFail, files, ACCESS_TOKEN) => [ 62 | upload, 63 | { 64 | success: httpSuccess, 65 | fail: httpFail, 66 | files: files, 67 | ACCESS_TOKEN: ACCESS_TOKEN 68 | } 69 | ] 70 | const takePicture = (dispatch, { action }) => { 71 | const image = requestHandler.takeBase64Photo({ type: 'jpeg', quality: 0.8 }).base64 72 | if (image) { 73 | dispatch(action, { tabStatus: IMAGE_STATE.TAKEN, images: [image] }) 74 | } else { 75 | dispatch(action, { tabStatus: IMAGE_STATE.INIT, images: [] }) 76 | } 77 | } 78 | export const takePictureFx = (action) => [ 79 | takePicture, 80 | { 81 | action 82 | } 83 | ] 84 | const startRecording = (dispatch, { action }) => { 85 | try { 86 | requestHandler.start() 87 | dispatch(action, { status: AUDIO_STATE.RECORDING }) 88 | } catch (e) { 89 | dispatch(action, { status: AUDIO_STATE.INIT }) 90 | } 91 | } 92 | export const startRecordingFx = (action) => [ 93 | startRecording, 94 | { 95 | action 96 | } 97 | ] 98 | const stopRecording = (dispatch, { action }) => { 99 | try { 100 | requestHandler.stop() // triggers event handler on the audio element 101 | } catch (e) { 102 | dispatch(action, { status: AUDIO_STATE.INIT }) 103 | } 104 | } 105 | export const stopRecordingFx = (action) => [ 106 | stopRecording, 107 | { 108 | action 109 | } 110 | ] 111 | const addToLocalStore = (dispatch, { images, recordings }) => { 112 | requestHandler.storeLocalItems(images, 'jpeg') 113 | requestHandler.storeLocalItems(recordings, 'webm') 114 | } 115 | export const addToLocalStoreFx = (images, recordings) => [ 116 | addToLocalStore, 117 | { 118 | images, 119 | recordings 120 | } 121 | ] 122 | const removeFromLocalStore = (dispatch, config = {}) => { 123 | requestHandler.removeLocalItems() 124 | } 125 | export const removeFromLocalStoreFx = () => [ 126 | removeFromLocalStore, 127 | {} 128 | ] 129 | -------------------------------------------------------------------------------- /src/handleRequests.js: -------------------------------------------------------------------------------- 1 | import { map, filter, pipe, curry } from 'ramda' 2 | import * as idb from 'idb-keyval' 3 | /** The camera handling function 4 | * Based on [@link https://dev.to/ore/building-an-offline-pwa-camera-app-with-react-and-cloudinary-5b9k] 5 | */ 6 | let webcamElement = HTMLVideoElement 7 | let canvasElement = HTMLCanvasElement 8 | /** initialise the camera 9 | * @return {Promise} A promise to connect to the camera 10 | */ 11 | export const setupVideo = () => { 12 | webcamElement = getWebcam(); 13 | canvasElement = document.createElement('canvas'); 14 | if (navigator.mediaDevices.getUserMedia !== undefined) { 15 | const userMediaResponse = navigator.mediaDevices.getUserMedia({ 16 | audio: false, video: { facingMode: 'environment' } // facingMode is 'user' for selfie cam 17 | }) 18 | .then((mediaStream) => { 19 | if ("srcObject" in webcamElement) { 20 | webcamElement.srcObject = mediaStream; 21 | } else { 22 | // For older browsers without the srcObject. 23 | webcamElement.src = window.URL.createObjectURL(mediaStream); 24 | } 25 | webcamElement.addEventListener( 26 | 'loadeddata', 27 | async () => { 28 | const adjustedSize = adjustVideoSize( 29 | webcamElement.videoWidth, 30 | webcamElement.videoHeight 31 | ); 32 | }, 33 | false 34 | ); 35 | return 36 | }) 37 | .catch((e) => console.log(e)) 38 | return Promise.resolve(userMediaResponse) 39 | } else { 40 | alert('Your browser does not support video') 41 | } 42 | } 43 | /** Take a photo as a canvas Blob 44 | * @return {Promise} A promise to return and object with the blob, height, width 45 | */ 46 | export const takeBlobPhoto = () => { 47 | const { imageWidth, imageHeight } = drawImage(); 48 | return new Promise((resolve, reject) => { 49 | return canvasElement.toBlob((blob) => { 50 | return resolve({ blob, imageHeight, imageWidth }); 51 | }); 52 | }); 53 | } 54 | /** Take a photo as a base64 image 55 | * @param {object} - An object defining type and quality, defaults are 'png', 1 56 | * @return {object} an object with the base64 image, height, width 57 | */ 58 | export const takeBase64Photo = ({ type, quality } = { type: 'png', quality: 1 }) => { 59 | const { imageHeight, imageWidth } = drawImage(); 60 | const base64 = canvasElement.toDataURL('image/' + type, quality); 61 | return { base64, imageHeight, imageWidth }; 62 | } 63 | // define the dimensions of the streamed video 64 | const adjustVideoSize = (width, height) => { 65 | const aspectRatio = width / height; 66 | if (width >= height) { 67 | webcamElement.width = aspectRatio * webcamElement.height; 68 | } else { 69 | webcamElement.height = webcamElement.width / aspectRatio; 70 | } 71 | return webcamElement 72 | } 73 | // define the required dimensions for a new image 74 | const drawImage =() => { 75 | const imageWidth = webcamElement.videoWidth; 76 | const imageHeight = webcamElement.videoHeight; 77 | 78 | const context = canvasElement.getContext('2d'); 79 | canvasElement.width = imageWidth; 80 | canvasElement.height = imageHeight; 81 | 82 | const drawnImage = context.drawImage(webcamElement, 0, 0, imageWidth, imageHeight); 83 | return { imageHeight, imageWidth }; 84 | } 85 | const getWebcam = () => document.getElementById('webcam') 86 | const getCanvas = () => document.getElementById('photoCanvas') 87 | 88 | export const findLocalItems = (type) => { 89 | const query = type + '_pwa_' 90 | return idb.keys() // IndexedDb key list 91 | .then(function (items) { 92 | const promises = pipe(filter(item => item.includes(query)), map((item) => idb.get(item)))(items) 93 | return Promise.all(promises) 94 | .then((result) => result ? result : []) 95 | }) 96 | } 97 | export const storeLocalItems = (item, type) => { 98 | if (item && (item.length === undefined || item.length === 0)) return 99 | // create a random string with a prefix 100 | const prefix = type + '_pwa_'; 101 | // create random string 102 | const rs = Math.random().toString(36).substr(2, 5); 103 | const key = `${prefix}${rs}` 104 | if (Array.isArray(item)) { 105 | const storeItem = curry(idb.set)(key) 106 | return map(storeItem, item) 107 | } 108 | return idb.set(key, item); 109 | } 110 | export const removeLocalItems = () => { 111 | return idb.clear() 112 | } 113 | 114 | let recorder = undefined 115 | const mimeType = 'audio/webm'; 116 | let chunks = []; 117 | let returnDataCB 118 | export const setupAudio = function (action, dispatch, readyStatus) { 119 | if ('MediaRecorder' in window) { 120 | try { 121 | navigator.mediaDevices.getUserMedia({ 122 | audio: true, 123 | video: false 124 | }) 125 | .then(function (stream) { 126 | recorder = new MediaRecorder(stream, { type: mimeType }); 127 | recorder.addEventListener('dataavailable', event => { 128 | if (typeof event.data === 'undefined') return; 129 | if (event.data.size === 0) return; 130 | chunks.push(event.data); 131 | const blob = new Blob(chunks, { type: mimeType }); 132 | // https://stackoverflow.com/questions/18650168/convert-blob-to-base64 133 | // setup a local audio URL to enable playback in the browser 134 | const audioURL = window.URL.createObjectURL(blob); 135 | // audio.src = audioURL; 136 | let recording 137 | const reader = new window.FileReader(); 138 | reader.readAsDataURL(blob); 139 | reader.onloadend = function () { 140 | recording = reader.result; 141 | chunks = []; 142 | dispatch(action, { 143 | status: readyStatus, 144 | recordings: [recording], 145 | url: audioURL 146 | }) 147 | } 148 | }); 149 | }) 150 | } catch { 151 | return 'You denied access to the microphone so this feature will not work.' 152 | } 153 | } else { 154 | throw ('Your browser does not support audio recording.') 155 | } 156 | } 157 | export const start = () => { 158 | recorder.start() 159 | return true 160 | } 161 | export const stop = () => recorder.stop() 162 | -------------------------------------------------------------------------------- /src/img/icons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnkazer/hyperapp-xstate-demo/fe419c528cd6a68b079eb5337a8a4ecf43cc6ce3/src/img/icons/favicon.ico -------------------------------------------------------------------------------- /src/img/icons/icon-1024x1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnkazer/hyperapp-xstate-demo/fe419c528cd6a68b079eb5337a8a4ecf43cc6ce3/src/img/icons/icon-1024x1024.png -------------------------------------------------------------------------------- /src/img/icons/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnkazer/hyperapp-xstate-demo/fe419c528cd6a68b079eb5337a8a4ecf43cc6ce3/src/img/icons/icon-192x192.png -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Hyperapp Demo 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import { app, h, text } from 'hyperapp' 2 | import { pugToView } from "./pug-to-view" 3 | import { initialStateObj } from './actions.js' 4 | import { subs } from './subscriptions.js' 5 | import { registerServiceWorker } from './serviceWorkerHandler.js' 6 | 7 | if ('serviceWorker' in navigator) { 8 | try { 9 | registerServiceWorker(); 10 | } catch (e) { 11 | console.error(e); 12 | } 13 | } 14 | 15 | const view = pugToView(h, text) 16 | const node = document.getElementById('app') 17 | app({ 18 | init: initialStateObj, 19 | view: view, 20 | node: node, 21 | subscriptions: subs 22 | }) 23 | -------------------------------------------------------------------------------- /src/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Hyperapp Demo", 3 | "short_name": "hyperappDemo", 4 | "description": "An app to demo hyperapp, intended to be compared to the app of similar function but using xstate as well.", 5 | "theme_color": "#2196f3", 6 | "background_color": "#2196f3", 7 | "display": "standalone", 8 | "Scope": "/", 9 | "start_url": "/", 10 | "homepage_url": "localhost:1234", 11 | "icons": [ 12 | { 13 | "src": "img/icons/favicon.ico", 14 | "sizes": "64x64 32x32 24x24 16x16", 15 | "type": "image/x-icon" 16 | }, 17 | { 18 | "src": "img/icons/icon-192x192.png", 19 | "sizes": "192x192", 20 | "type": "image/png" 21 | }, 22 | { 23 | "src": "img/icons/icon-1024x1024.png", 24 | "sizes": "1024x1024", 25 | "type": "image/png" 26 | } 27 | ], 28 | "splash_pages": null 29 | } 30 | -------------------------------------------------------------------------------- /src/pug-to-view.js: -------------------------------------------------------------------------------- 1 | 2 | import 'pug-vdom/runtime' // runtime library is required and puts 'pugVDOMRuntime' into the global scope 3 | const render = require('./app.pug.js') 4 | export const pugToView = (h, text) => state => 5 | render( 6 | state, 7 | (name, props, children) => 8 | h(name, props.attributes, children), 9 | text 10 | )[0] 11 | -------------------------------------------------------------------------------- /src/pug-vdom.js: -------------------------------------------------------------------------------- 1 | var lex = require('pug-lexer') 2 | var parse = require('pug-parser') 3 | var linker = require('pug-linker') 4 | var load = require('pug-load') 5 | var fs = require('fs') 6 | 7 | function buildAst (filename, basedir, options) { 8 | if (filename.substr(-4) !== '.pug') { 9 | filename = filename + '.pug'; 10 | } 11 | var buf = fs.readFileSync(filename, 'utf8') 12 | var ast = parse(lex(buf.toString()), { filename }) 13 | ast = load(ast, Object.assign({}, options, {lex: lex, parse: parse, basedir: basedir })) 14 | ast = linker(ast) 15 | return ast 16 | } 17 | 18 | function pugTextToAst (pugText) { 19 | var ast = parse(lex(pugText.trim())) 20 | ast = linker(ast) 21 | return ast 22 | } 23 | 24 | function generateTemplateFunction(pugText) { 25 | return eval('('+new Compiler(pugTextToAst(pugText)).compile()+')'); 26 | } 27 | 28 | var _id = 0 29 | function uid () { 30 | _id++ 31 | return _id 32 | } 33 | 34 | function Compiler (ast) { 35 | this.ast = ast 36 | this.indent = 1 37 | this.parentId = 0 38 | this.parentTagId = 0 39 | this.buffer = [] 40 | } 41 | 42 | Compiler.prototype.add = function (str) { 43 | this.buffer.push(str) 44 | } 45 | 46 | Compiler.prototype.addI = function (str) { 47 | this.buffer.push(Array(this.indent).join(' ') + str) 48 | } 49 | 50 | Compiler.prototype.compile = function () { 51 | this.bootstrap() 52 | return this.buffer.join('') 53 | } 54 | 55 | Compiler.prototype.bootstrap = function () { 56 | this.addI(`function render(context, h, text = (string) => string) {\r\n`) 57 | this.indent++ 58 | this.addI(`if (!pugVDOMRuntime) throw "pug-vdom runtime not found.";\r\n`) 59 | this.addI(`var runtime = pugVDOMRuntime\r\n`) 60 | // Bring all the variables from this into this scope 61 | this.addI(`var locals = context;\r\n`) 62 | this.addI(`var self = locals;\r\n`) 63 | this.addI(`var remainingKeys = pugVDOMRuntime.exposeLocals(locals);\r\n`) 64 | this.addI(`for (var prop in remainingKeys) {\r\n`) 65 | this.indent++ 66 | this.addI(`eval('var ' + prop + ' = locals.' + prop);\r\n`) 67 | this.indent-- 68 | this.addI(`}\r\n`) 69 | this.addI(`var n0Child = []\r\n`) 70 | this.visit(this.ast) 71 | this.addI(`pugVDOMRuntime.deleteExposedLocals()\r\n`) 72 | this.addI(`return n0Child\r\n`) 73 | this.indent-- 74 | this.addI(`}\r\n`) 75 | } 76 | 77 | Compiler.prototype.visit = function (node, parent) { 78 | if (!this['visit' + node.type]) { 79 | throw new Error('Node not handled: ' + node.type) 80 | } 81 | this['visit' + node.type](node, parent) 82 | } 83 | 84 | Compiler.prototype.visitBlock = function (node, parent) { 85 | for (var i = 0; i < node.nodes.length; ++i) { 86 | this.visit(node.nodes[i], node) 87 | } 88 | } 89 | 90 | Compiler.prototype.visitTag = function (node, parent) { 91 | var id = uid() 92 | this.addI(`var n${id}Child = []\r\n`) 93 | var s = this.parentTagId 94 | this.parentTagId = id 95 | this.visitBlock(node.block, node) 96 | this.addI(`var props = {attributes: runtime.compileAttrs([${node.attrs.map(attr => '{name:\'' + attr.name + '\', val: ' + attr.val + '}').join(',')}], [${node.attributeBlocks.join(',')}])};\r\n`); 97 | this.addI(`if (props.attributes.id) props.key = props.attributes.id;\r\n`); 98 | this.addI(`var n${id} = h(${node.name ? `'${node.name}'` : `${node.expr}`}, props, n${id}Child)\r\n`) 99 | this.parentTagId = s 100 | this.addI(`n${s}Child.push(n${id})\r\n`) 101 | } 102 | 103 | Compiler.prototype.visitInterpolatedTag = Compiler.prototype.visitTag; 104 | Compiler.prototype.visitText = function (node, parent) { 105 | var val = node.val; 106 | var s = JSON.stringify(val) 107 | if (val[0] === '<') { 108 | this.addI(`n${this.parentTagId}Child = n${this.parentTagId}Child.concat(runtime.makeHtmlNode(${s}))\r\n`) 109 | } else { 110 | this.addI(`n${this.parentTagId}Child.push(text(${s}))\r\n`) 111 | } 112 | } 113 | 114 | Compiler.prototype.visitNamedBlock = function (node, parent) { 115 | this.visitBlock(node, parent) 116 | } 117 | 118 | Compiler.prototype.visitCode = function (node, parent) { 119 | if (node.buffer) { 120 | this.addI(`n${this.parentTagId}Child = n${this.parentTagId}Child.concat(${node.mustEscape ? `text(${node.val})` : `runtime.makeHtmlNode(${node.val})`})\r\n`) 121 | } else { 122 | this.addI(node.val + '\r\n') 123 | } 124 | 125 | if(node.block){ 126 | this.addI('{\r\n') 127 | this.indent++ 128 | this.visitBlock(node.block, node) 129 | this.indent-- 130 | this.addI('}\r\n') 131 | } 132 | } 133 | 134 | Compiler.prototype.visitConditional = function (node, parent) { 135 | this.addI(`if(${node.test}) {\r\n`) 136 | this.indent++ 137 | this.visitBlock(node.consequent, node) 138 | this.indent-- 139 | if (node.alternate) { 140 | this.addI(`} else {\r\n`) 141 | this.indent++ 142 | this.visit(node.alternate, node) 143 | this.indent-- 144 | } 145 | this.addI(`}\r\n`) 146 | } 147 | 148 | Compiler.prototype.visitComment = function (node, parent) {} 149 | 150 | Compiler.prototype.visitBlockComment = function(node, parent) {} 151 | 152 | Compiler.prototype.visitWhile = function (node) { 153 | this.addI(`while (${node.test}){\r\n`); 154 | this.indent++ 155 | this.visitBlock(node.block); 156 | this.indent-- 157 | this.addI(`}\r\n`); 158 | } 159 | 160 | Compiler.prototype.visitEach = function (node, parent) { 161 | var tempVar = 'v' + uid() 162 | var key = node.key || 'k' + uid() 163 | 164 | this.addI(`var ${tempVar} = ${node.obj}\r\n`) 165 | this.addI(`Object.keys(${tempVar}).forEach(function (${key}) {\r\n`) 166 | this.indent++ 167 | this.addI(`var ${node.val} = ${tempVar}[${key}]\r\n`) 168 | this.visitBlock(node.block) 169 | this.indent-- 170 | this.addI(`}.bind(this))\r\n`) 171 | } 172 | 173 | Compiler.prototype.visitExtends = function (node, parent) { 174 | throw new Error('Extends nodes need to be resolved with pug-load and pug-linker') 175 | } 176 | 177 | Compiler.prototype.visitMixin = function (node, parent) { 178 | var s = this.parentTagId 179 | if (node.call) { 180 | if(node.block) { // the call mixin define a block 181 | var id = uid() 182 | this.parentTagId = id 183 | this.indent++ 184 | this.addI(`var n${id}Child = []\r\n`) 185 | this.visitBlock(node.block, node) 186 | var args = node.args ? `${node.args}, n${id}Child` : `n${id}Child` 187 | this.addI(`n${s}Child.push(${node.name}(${args}));\r\n`) 188 | this.indent-- 189 | this.parentTagId = s 190 | } else { 191 | this.addI(`n${s}Child.push(${node.name}(${node.args}));\r\n`) 192 | } 193 | return 194 | } 195 | var id = uid() 196 | this.parentTagId = id 197 | var args = node.args ? `${node.args}, __block` : `__block` 198 | this.addI(`function ${node.name}(${args}) {\r\n`) 199 | this.indent++ 200 | this.addI(`var n${id}Child = []\r\n`) 201 | if (node.block) { 202 | this.visitBlock(node.block, node) 203 | } 204 | this.addI(`return n${id}Child\r\n`) 205 | this.indent-- 206 | this.parentTagId = s 207 | this.addI(`}\r\n`) 208 | } 209 | 210 | Compiler.prototype.visitMixinBlock = function (node, parent) { 211 | this.addI(`n${this.parentTagId}Child.push(__block);\r\n`) 212 | } 213 | 214 | Compiler.prototype.visitCase = function (node, parent) { 215 | this.addI(`switch(${node.expr}) {\r\n`) 216 | var self = this 217 | node.block.nodes.forEach(function (_case, index) { 218 | self.indent++ 219 | self.visit(_case) 220 | self.indent-- 221 | }) 222 | this.addI(`}\r\n`) 223 | } 224 | 225 | Compiler.prototype.visitWhen = function (node, parent) { 226 | if (node.expr === 'default') { 227 | this.addI(`default:\r\n`) 228 | } else { 229 | this.addI(`case ${node.expr}:\r\n`) 230 | } 231 | this.indent++ 232 | if (node.block) { 233 | this.visit(node.block, node) 234 | } 235 | this.addI(`break;\r\n`) 236 | this.indent-- 237 | } 238 | 239 | function generateFile (file, out, basedir) { 240 | var ast = buildAst(file, basedir || '.') 241 | var compiler = new Compiler(ast) 242 | var code = '// PUG VDOM generated file\r\n' + compiler.compile() 243 | code += '\r\nmodule.exports = render\r\n' 244 | fs.writeFileSync(out, code) 245 | } 246 | 247 | 248 | module.exports = { 249 | ast: buildAst, 250 | generateTemplateFunction: generateTemplateFunction, 251 | pugTextToAst: pugTextToAst, 252 | generateFile: generateFile, 253 | Compiler: Compiler 254 | } 255 | -------------------------------------------------------------------------------- /src/serviceWorkerHandler.js: -------------------------------------------------------------------------------- 1 | function urlBase64ToUint8Array(base64String) { 2 | const padding = '='.repeat((4 - base64String.length % 4) % 4); 3 | const base64 = (base64String + padding) 4 | .replace(/\-/g, '+') 5 | .replace(/_/g, '/') 6 | ; 7 | const rawData = window.atob(base64); 8 | return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0))); 9 | } 10 | 11 | async function subscribeToPushNotifications(registration) { 12 | if ('pushManager' in registration) { 13 | const options = { 14 | userVisibleOnly: true, 15 | applicationServerKey: urlBase64ToUint8Array('BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U'), 16 | }; 17 | 18 | const status = await pushStatus; 19 | 20 | if (status) { 21 | try { 22 | const subscription = await registration.pushManager.subscribe(options); 23 | //Received subscription 24 | return subscription 25 | } catch (e) { 26 | return console.error('Push registration failed', e); 27 | } 28 | } 29 | } 30 | return console.error('Push registration failed') 31 | } 32 | 33 | export async function registerServiceWorker() { 34 | try { 35 | const swFilename = './sw.js' 36 | const registration = await navigator.serviceWorker.register(swFilename); 37 | return subscribeToPushNotifications(registration); 38 | } catch (e) { 39 | return console.error('ServiceWorker failed', e); 40 | } 41 | } 42 | 43 | const pushStatus = new Promise((resolve, reject) => { 44 | return Notification.requestPermission(result => { 45 | const el = document.createElement('div'); 46 | el.classList.add('push-info'); 47 | 48 | if (result !== 'granted') { 49 | el.classList.add('inactive'); 50 | el.textContent = 'Push blocked'; 51 | } else { 52 | el.classList.add('active'); 53 | el.textContent = 'Push active'; 54 | } 55 | document.body.appendChild(el) 56 | return result 57 | }) 58 | }); 59 | -------------------------------------------------------------------------------- /src/subscriptions.js: -------------------------------------------------------------------------------- 1 | import * as actions from './actions.js' 2 | import * as requestHandler from './handleRequests.js' 3 | import * as effects from './effects.js' 4 | 5 | const initMedia = (dispatch, { action, status }) => { 6 | window.addEventListener('load', (event) => { 7 | setTimeout(() => { // ensure call setup after load has really finished 8 | try { 9 | requestHandler.setupVideo() 10 | requestHandler.setupAudio(action, dispatch, status) 11 | } catch(e) { 12 | return alert(e); 13 | } 14 | }, 100) 15 | }) 16 | } 17 | const onlineStatus = (dispatch, { action }) => { 18 | window.addEventListener('online', async () => { 19 | const images = await requestHandler.findLocalItems('jpeg'); 20 | const recordings = await requestHandler.findLocalItems('webm'); 21 | return dispatch(action, { status: 'online', images, recordings }) 22 | }); 23 | 24 | window.addEventListener('offline', () => { 25 | return dispatch(action, { status: 'offline' }) 26 | }); 27 | } 28 | const handleInstallState = (dispatch, { action }) => { 29 | window.addEventListener('beforeinstallprompt', (e) => { 30 | // Prevent Chrome 67 and earlier from automatically showing the app install prompt 31 | e.preventDefault(); 32 | // Stash the event so it can be triggered later. 33 | const deferredPrompt = e 34 | const installed = false 35 | return dispatch(action, { deferredPrompt, installed }) 36 | }); 37 | } 38 | const initMediaSub = ({ action, status }) => [ 39 | initMedia, 40 | { 41 | action, 42 | status 43 | } 44 | ] 45 | const onlineStatusSub = ({ action }) => [ 46 | onlineStatus, 47 | { 48 | action 49 | } 50 | ] 51 | const handleInstallStateSub = ({ action }) => [ 52 | handleInstallState, 53 | { 54 | action 55 | } 56 | ] 57 | export const subs = (state) => [ 58 | initMediaSub({ 59 | action: actions.audioReady, 60 | status: effects.AUDIO_STATE.READY 61 | }), 62 | onlineStatusSub({ 63 | action: actions.updateStatus 64 | }), 65 | handleInstallStateSub({ 66 | action: actions.handleInstallState 67 | }) 68 | ] 69 | -------------------------------------------------------------------------------- /src/sw.js: -------------------------------------------------------------------------------- 1 | const files = [ 2 | './index.html', 3 | './default.html', 4 | './main.1f19ae8e.js', 5 | './style.78032849.css', 6 | './style.78032849.js', 7 | './manifest.webmanifest', 8 | 'https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css' 9 | ]; 10 | 11 | self.addEventListener('install', async e => { 12 | const cache = await caches.open('files'); 13 | cache.addAll(files); 14 | }); 15 | 16 | async function getFromCache(req) { 17 | const res = await caches.match(req); 18 | 19 | if (!res) { 20 | return fetch(req); 21 | } 22 | 23 | return res; 24 | } 25 | 26 | async function getFallback(req) { 27 | return caches.match('./default.html'); 28 | } 29 | 30 | async function getFromNetwork(req) { 31 | const cache = await caches.open('data'); 32 | 33 | try { 34 | const res = await fetch(req); 35 | cache.put(req, res.clone()); 36 | return res; 37 | } catch (e) { 38 | const res = await cache.match(req); 39 | return res || getFallback(req); 40 | } 41 | } 42 | 43 | self.addEventListener('fetch', async e => { 44 | const req = e.request; 45 | const res = navigator.onLine ? getFromNetwork(req) : getFromCache(req); 46 | await e.respondWith(res); 47 | }); 48 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import { map, curry } from 'ramda' 2 | 3 | export function resetImage (state, newStatus) { 4 | const images = [] 5 | const uploadingStatusMsg = 'Not uploading' 6 | const buttons = resetButtonState(state.buttons, newStatus) 7 | return { ...state, images, buttons } 8 | } 9 | export function resetAudio (state, newStatus) { 10 | const recordings = [] 11 | const audioUrl = [] 12 | const uploadingStatusMsg = 'Not uploading' 13 | const recordingStatusMsg = 'Not recording' 14 | const buttons = resetButtonState(state.buttons, newStatus) 15 | return { ...state, recordings, audioUrl, uploadingStatusMsg, recordingStatusMsg, buttons } 16 | } 17 | export const resetButtonState = (currentButtons, tabStatus) => { 18 | function updateButtons (newStatus, button) { 19 | if (button.usedBy === newStatus) { 20 | const active = true 21 | return { ...button, active } 22 | } else { 23 | const active = false 24 | return { ...button, active } 25 | } 26 | } 27 | if (tabStatus === 'both') return currentButtons 28 | const flipState = curry(updateButtons)(tabStatus) 29 | const buttons = map(flipState, currentButtons) 30 | return buttons 31 | } 32 | -------------------------------------------------------------------------------- /views/app.pug: -------------------------------------------------------------------------------- 1 | // always need a single node entry point, so start with a parent div 2 | div 3 | h1 #{title} 4 | if !installed 5 | button.btn(onclick=installAsPwa) Install 6 | p(class=status) #{onlineStatusMsg} 7 | p #{uploadingStatusMsg} 8 | - var showTab 9 | .tab.align-centre 10 | each button in tabs 11 | button.tabLinks(id=button.id onclick=button.action) #{button.txt} 12 | if (button.active) 13 | - showTab = button.tabName 14 | div.tabContent.surround(id='videoSelection' style=showTab === 'videoSelection' ? {display: 'block'} : {display: 'none'}) 15 | div.align-centre 16 | video(autoPlay playsInline muted id="webcam" width="100%" height="200") 17 | #imageCanvas.imageCanvas 18 | each img in images 19 | img(src=img alt="captured" height="200") 20 | br 21 | div.align-centre 22 | each button in buttons 23 | - var display = button.active ? 'block' : 'none' 24 | button.btn.btn-primary(id=button.id onclick=button.action style={display: display}) #{button.txt} 25 | div.tabContent.surround(id='audioSelection' style=showTab === 'audioSelection' ? {display: 'block'} : {display: 'none'}) 26 | p #{recordingStatusMsg} 27 | div.align-centre 28 | each button in buttons 29 | - var display = button.active ? 'block' : 'none' 30 | button.btn.btn-primary(id=button.id onclick=button.action style={display: display}) #{button.txt} 31 | if audioUrl.length 32 | each url in audioUrl 33 | audio(src = url, controls='controls' ) 34 | a(href=url) 35 | --------------------------------------------------------------------------------