diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c408a22 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +# http://EditorConfig.org +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..30680eb --- /dev/null +++ b/.eslintrc @@ -0,0 +1,14 @@ +{ + "root": true, + "extends": "airbnb-base", + "env": { + "browser": true, + "node": true + }, + "rules": { + "import/extensions": 0, + "import/no-extraneous-dependencies": 0, + "import/no-unresolved": [2, { "ignore": ["electron"] }], + "no-param-reassign": ["error", { "props": false }] + } +} diff --git a/.gitignore b/.gitignore index 2909082..34a9938 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ -dist node_modules -npm-debug.log -debug.log +*.log .DS_Store +dist diff --git a/app/elements/action-button/action-button.html b/app/elements/action-button/action-button.html new file mode 100644 index 0000000..b7a4361 --- /dev/null +++ b/app/elements/action-button/action-button.html @@ -0,0 +1,92 @@ + + + diff --git a/app/elements/action-button/action-button.js b/app/elements/action-button/action-button.js new file mode 100644 index 0000000..8595ae5 --- /dev/null +++ b/app/elements/action-button/action-button.js @@ -0,0 +1,105 @@ +/* eslint-disable no-underscore-dangle */ + +class ActionButton extends HTMLElement { + + static get observedAttributes() { + return ['label', 'icon']; + } + + constructor() { + super(); + + const ownerDocument = document.currentScript.ownerDocument; + const template = ownerDocument.querySelector('#action-button-template'); + const shadowRoot = this.attachShadow({ mode: 'open' }); + shadowRoot.appendChild(template.content.cloneNode(true)); + + this._components = { + container: shadowRoot.querySelector('.container'), + button: shadowRoot.querySelector('.button'), + label: shadowRoot.querySelector('.label'), + icon: shadowRoot.querySelector('.icon'), + }; + this._action = undefined; + } + + getAttributeValue(attributeName) { + return this.hasAttribute(attributeName) ? this.getAttribute(attributeName) : ''; + } + + get label() { + return this.getAttributeValue(this._components.label.className); + } + + set label(label) { + if (!label) { + return; + } + + this._components.label.textContent = label; + } + + get icon() { + return this.getAttributeValue(this._components.icon.className); + } + + set icon(icon) { + if (!icon) { + return; + } + + this._components.icon.setAttribute('d', icon); + } + + connectedCallback() { + // Action is a noop function by default + this.setAction(() => {}); + } + + disconnectedCallback() { + // Remove action from element on callback + this.removeAction(); + } + + attributeChangedCallback(attributeName, oldValue, newValue) { + if (attributeName === this._components.label.className) { + this.label = newValue; + // Must check with baseVal property for icon because the className is a SVGAnimatedString object + } else if (attributeName === this._components.icon.className.baseVal) { + this.icon = newValue; + } + } + + setAction(action) { + if (typeof action !== 'function') { + return; + } + + // Remove the current action and replace it with the new one + this.removeAction(); + this._action = action; + this._components.button.addEventListener('click', this._action); + } + + removeAction() { + this._components.button.removeEventListener('click', this._action); + } + + hideButton() { + this.setHidden(true); + } + + showButton() { + this.setHidden(false); + } + + setHidden(hidden) { + if (typeof hidden !== 'boolean') { + return; + } + + this._components.container.style.display = hidden ? 'none' : ''; + } +} + +window.customElements.define('action-button', ActionButton); diff --git a/app/main.js b/app/main.js index aa9ddd0..f593771 100644 --- a/app/main.js +++ b/app/main.js @@ -1,42 +1,65 @@ -const {app, BrowserWindow, ipcMain} = require('electron'); +const { app, BrowserWindow, ipcMain } = require('electron'); -let mainWindow, setupWindow, pauseWindow; +/** @constant { boolean } + * + * Checks if the current environment is in development by looking for the asar package. + * If the package exists, then the app is in production mode. + * Otherwise (if it is not found), then the program is in development mode. + */ +const DEVELOPMENT = process.mainModule.filename.indexOf('app.asar') === -1; + +let mainWindow = null; +let setupWindow = null; +let pauseWindow = null; + +const initializeWindow = (windowType, windowName) => { + windowType.loadURL(`file://${__dirname}/views/${windowName}.html`); + windowType.setMenu(null); + if (DEVELOPMENT) { + windowType.webContents.openDevTools(); + } + + windowType.once('ready-to-show', windowType.show); +}; + +const exitApp = () => { + // Do not exit the program on macOS (standard OS-specific behaviour). + // Instead, lose app focus and close all open windows. + if (process.platform === 'darwin') { + app.hide(); + BrowserWindow.getAllWindows().forEach(win => win.close()); + } else { + app.quit(); + } +}; -function createMainWindow () { +const createMainWindow = () => { mainWindow = new BrowserWindow({ fullscreen: true, - frame: false + frame: false, }); - mainWindow.loadURL(`file://${__dirname}/views/index.html`); - mainWindow.setMenu(null); - - mainWindow.once('ready-to-show', mainWindow.show); + initializeWindow(mainWindow, 'index'); - mainWindow.on('closed', () => mainWindow = null); -} + mainWindow.on('closed', () => (mainWindow = null)); +}; -function createSetupModalWindow () { +const createSetupModalWindow = () => { setupWindow = new BrowserWindow({ parent: mainWindow, modal: true, minWidth: 400, minHeight: 300, - frame: false + frame: false, }); - setupWindow.loadURL(`file://${__dirname}/views/setup.html`); - setupWindow.setMenu(null); - - setupWindow.once('ready-to-show', setupWindow.show); + initializeWindow(setupWindow, 'setup'); setupWindow.on('close', exitApp); - setupWindow.on('closed', () => setupWindow = null); -} + setupWindow.on('closed', () => (setupWindow = null)); +}; -function createPauseModalWindow () { - mainWindow.webContents.executeJavaScript( - 'document.body.classList.add(\'dim\')' - ); +const createPauseModalWindow = () => { + mainWindow.webContents.executeJavaScript('document.body.classList.add(\'dim\')'); pauseWindow = new BrowserWindow({ parent: mainWindow, @@ -45,38 +68,22 @@ function createPauseModalWindow () { height: 250, resizable: false, closable: false, - frame: false + frame: false, }); - pauseWindow.loadURL(`file://${__dirname}/views/pause.html`); - pauseWindow.setMenu(null); - - pauseWindow.once('ready-to-show', pauseWindow.show); + initializeWindow(pauseWindow, 'pause'); pauseWindow.on('close', exitApp); pauseWindow.on('closed', () => { - mainWindow.webContents.executeJavaScript( - 'document.body.classList.remove(\'dim\')' - ); + mainWindow.webContents.executeJavaScript('document.body.classList.remove(\'dim\')'); pauseWindow = null; }); -} +}; -function createStartWindows () { +const createStartWindows = () => { createMainWindow(); createSetupModalWindow(); -} - -function exitApp () { - // Do not exit the program on macOS (standard OS-specific behaviour). - // Instead, lose app focus and close all open windows. - if (process.platform === 'darwin') { - app.hide(); - BrowserWindow.getAllWindows().forEach(win => win.close()); - } else { - app.quit(); - } -} +}; app.on('ready', createStartWindows); @@ -84,9 +91,7 @@ app.on('window-all-closed', exitApp); app.on('activate', createStartWindows); -ipcMain.on('setup-timer', (evt, settings) => - mainWindow.webContents.send('start-timer', settings) -); +ipcMain.on('setup-timer', (evt, settings) => mainWindow.webContents.send('start-timer', settings)); ipcMain.on('pause', createPauseModalWindow); @@ -94,15 +99,15 @@ ipcMain.on('pause', createPauseModalWindow); * Called after the pause window has been opened and it is safe to wait for a * synchronous reply before continuing the counter. * There is undoubtedly a better way of handling pause, but this works for now. - * + * * @return the false boolean value for the paused flag in mainWindow */ -ipcMain.on('pause-wait', evt => { +ipcMain.on('pause-wait', (evt) => { // If it has already been closed before this channel, then return immediately - if (pauseWindow == null) { + if (pauseWindow === null) { evt.returnValue = false; } else { - pauseWindow.on('closed', () => evt.returnValue = false); + pauseWindow.on('closed', () => (evt.returnValue = false)); } }); diff --git a/app/package.json b/app/package.json deleted file mode 100644 index 04101f3..0000000 --- a/app/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "station-timer", - "productName": "Station Timer", - "version": "1.0.0", - "description": "A simple timer application that repeatedly counts down for a given amount of times", - "author": "Ahmad Ouerfelli ", - "license": "MIT", - "repository": { - "type": "git", - "url": "https://github.com/aouerfelli/station-timer" - }, - "bugs": "https://github.com/aouerfelli/station-timer/issues", - "main": "./main.js" -} \ No newline at end of file diff --git a/app/renderers/index.js b/app/renderers/index.js index e6d2532..21e481e 100644 --- a/app/renderers/index.js +++ b/app/renderers/index.js @@ -1,224 +1,267 @@ -const {ipcRenderer, remote} = require('electron'); -const webContents = remote.getCurrentWebContents(); +const { ipcRenderer, remote } = require('electron'); +const numberToWords = require('number-to-words'); -const counterTextView = document.getElementById('counter'); -const secondProgressBar = document.getElementById('progress'); -const infoTextView = document.getElementById('info'); -const pauseButton = document.getElementById('pause'); -const restartButton = document.getElementById('restart'); -const muteOnButton = document.getElementById('mute-on'); -const muteOffButton = document.getElementById('mute-off'); -const exitButton = document.getElementById('exit'); - -const beepAudio = new Audio('../assets/audio/beep.wav'); +// Object that caches all the DOM elements used +const domElements = { + remaining: document.querySelector('#remaining'), + counter: document.querySelector('#counter'), + progress: document.querySelector('#progress'), + info: document.querySelector('#info'), + buttons: { + pause: document.querySelector('#pause'), + restart: document.querySelector('#restart'), + muteOn: document.querySelector('#mute-on'), + muteOff: document.querySelector('#mute-off'), + exit: document.querySelector('#exit'), + }, + audio: { + beep: document.querySelector('#beep'), + }, +}; // Object containing strings used in the counter const text = { - counterText: { - end: '0' + remaining: { + multiple: ' stations remaining', + single: 'Last station', + none: 'No more stations', }, - infoText: { + counter: { + end: '0', + }, + info: { active: 'Complete your activity', coolDown: 'Go to your next station', - complete: 'Return to your original station' - } + complete: 'Return to your original station', + }, }; // Object containing values for duration, break duration and number of repeats -let settings; +let settings = null; // Flag indicating whether or not the program is currently in a paused state let paused = false; -pauseButton.addEventListener('click', () => { - paused = true; - ipcRenderer.send('pause'); -}); - -restartButton.addEventListener('click', () => - webContents.send('start-timer', settings) -); - -muteOnButton.addEventListener('click', () => { - webContents.setAudioMuted(true); - muteOnButton.parentElement.style.display = 'none'; - muteOffButton.parentElement.style.display = ''; -}); - -muteOffButton.addEventListener('click', () => { - webContents.setAudioMuted(false); - muteOffButton.parentElement.style.display = 'none'; - muteOnButton.parentElement.style.display = ''; -}); +const toSentenceCase = str => + // If a string is given and it is not empty, convert it to a "Sentence case" string + ((typeof str === 'string' && str.length > 0) ? + str.charAt(0).toUpperCase() + str.substring(1).toLowerCase() : + ''); -exitButton.addEventListener('click', () => - ipcRenderer.send('exit') -); +const ensureArray = arr => + // If a single element is given, place it in an array + (Array.isArray(arr) ? arr : [arr]); -counterTextView.addEventListener('click', () => { - // Since the counter has no pointer events when counting, this will only - // trigger at the end when the end class is added to the counter, which - // enables pointer events. - ipcRenderer.send('exit'); -}); +const optionalCallback = func => + // Executes a function if it is given and if not then a noop function is executed + (typeof func === 'function' ? func : () => {})(); -/** - * This function allows us to use Promises with generator functions, much like - * the async/await feature in ES7 (not supported in Electron v1.4.13). This - * allows us to write asyncronous code that looks similar to syncronous code. - * The basis for this function was derived from Jake Archibald's - * [JavaScript Promises: an Introduction]{@link https://developers.google.com/web/fundamentals/getting-started/primers/promises#bonus_round_promises_and_generators}. - * - * @param generatorFn the generator function that will yield Promises. - */ -function async (generatorFn) { - function continuer(verb, arg) { - let result; - try { - result = generator[verb](arg); - } catch (err) { - return Promise.reject(err); - } - return result.done ? - result.value : - Promise.resolve(result.value).then(onResolved, onRejected); - } +const skipTransition = (elements, action) => { + optionalCallback(action); + ensureArray(elements).forEach((el) => { + el.classList.add('skip-transition'); + (() => el.offsetHeight)(); // Trigger CSS reflow to flush changes + el.classList.remove('skip-transition'); + }); +}; - let generator = generatorFn(); - let onResolved = continuer.bind(continuer, 'next'); - let onRejected = continuer.bind(continuer, 'throw'); - return onResolved(); -} +const mute = () => { + remote.getCurrentWebContents().setAudioMuted(true); + domElements.buttons.muteOn.hideButton(); + domElements.buttons.muteOff.showButton(); +}; -function skipTransition (elements, action) { - // If a single element is given, place it in an array - if (elements.constructor !== Array) { - elements = [elements]; - } - // Run the action function if it is given - if (typeof action === 'function') { - action(); - } - elements.forEach(element => { - element.classList.add('skip-transition'); - element.offsetHeight; // Trigger CSS reflow to flush changes - element.classList.remove('skip-transition'); - }); -} +const unmute = () => { + remote.getCurrentWebContents().setAudioMuted(false); + domElements.buttons.muteOff.hideButton(); + domElements.buttons.muteOn.showButton(); +}; -function setProgressBar () { - skipTransition(secondProgressBar, () => - secondProgressBar.classList.remove('expand')); - secondProgressBar.classList.add('expand'); -} +const setProgressBar = () => { + skipTransition(domElements.progress, () => + domElements.progress.classList.remove('expand')); + domElements.progress.classList.add('expand'); +}; -function getFormattedTime (seconds) { - // Get units of time (from seconds up to hours) +const getFormattedTime = (seconds) => { + // --- Get units of time (from seconds up to hours) --- let hh = parseInt(seconds / 3600, 10); let mm = parseInt((seconds % 3600) / 60, 10); let ss = parseInt(seconds % 60, 10); - // Displaying or hiding units based on length of time (up to hours) - hh = hh > 0 ? hh + ':' : ''; - mm = hh === '' && mm <= 0 ? '' : - hh !== '' && mm < 10 ? '0' + mm + ':' : mm + ':'; - ss = mm === '' ? ss : - ss < 10 ? '0' + ss : ss; + // --- Displaying or hiding units based on length of time (up to hours) --- + // Hours + if (hh > 0) { + hh = `${hh}:`; + } else { + hh = ''; + } + // Minutes + if (hh === '' && mm <= 0) { + mm = ''; + } else if (hh !== '' && mm < 10) { + mm = `0${mm}:`; + } else { + mm = `${mm}:`; + } + // Seconds + if (mm !== '' && ss < 10) { + ss = `0${ss}`; + } - return hh + mm + ss; -} + return `${hh}${mm}${ss}`; +}; -function sleep (ms) { - return new Promise(resolve => setTimeout(resolve, ms)); -} +const sleep = ms => + new Promise(resolve => setTimeout(resolve, ms)); -function pauseWait () { +const pauseWait = () => // The pause-wait channel will return a value of false when the pause modal is // closed, which we can set to the paused flag. When the flag is set, the // Promise will be resolved. - return Promise.resolve(paused = ipcRenderer.sendSync('pause-wait')); -} - -function countdown (duration, view, onEachSecond) { - function action () { - // Run the onEachSecond function if it is given - if (typeof onEachSecond === 'function') { - onEachSecond(); - } - view.textContent = getFormattedTime(duration--); - } + Promise.resolve(paused = ipcRenderer.sendSync('pause-wait')); + +const countdown = (duration, counterView, onEachSecond) => { + let currentSecond = duration; - return Promise.resolve(async(function* () { - // We'll be decrementing duration each second in action() - while (duration > 0) { + const action = () => { + optionalCallback(onEachSecond); + counterView.textContent = getFormattedTime(currentSecond); + currentSecond -= 1; + }; + + return Promise.resolve((async () => { + // The current second will be decremented each second in the action function + while (currentSecond > 0) { + /* eslint-disable no-await-in-loop */ if (paused) { - yield pauseWait(); + // Since the loop should not continue when in a paused state, + // the loop is blocked until the pause promise is resolved + await pauseWait(); } else { action(); - yield sleep(1000); + // After completing the countdown decrementing action, + // the loop must be blocked for a second (1000ms) before resuming the countdown + await sleep(1000); } + /* eslint-enable no-await-in-loop */ } - // Check for pause before ending countdown + + // Check for pause request before ending the countdown if (paused) { - yield pauseWait(); + await pauseWait(); } - })); -} + })()); +}; + +domElements.buttons.pause.setAction(() => { + paused = true; + ipcRenderer.send('pause'); +}); + +domElements.buttons.restart.setAction(() => remote.getCurrentWebContents().send('start-timer', settings)); + +domElements.buttons.muteOn.setAction(() => { + mute(); +}); + +domElements.buttons.muteOff.setAction(() => { + unmute(); +}); + +domElements.buttons.exit.setAction(() => ipcRenderer.send('exit')); + +// Since the counter has no pointer events when counting, this will only +// trigger at the end when the end class is added to the counter, which +// enables pointer events. +domElements.counter.addEventListener('click', () => ipcRenderer.send('exit')); ipcRenderer.on('start-timer', (evt, userSettings) => { - let {duration, breakDuration, numRepeats} = settings = userSettings; + const { duration, breakDuration, numRepeats } = userSettings; + settings = userSettings; - function resetTimer () { + const resetTimer = () => { + // Set muted button based on whether or not the audio is mutedd + if (remote.getCurrentWebContents().isAudioMuted()) { + mute(); + } else { + unmute(); + } // Reset elements to their intended initial visibility - restartButton.parentElement.style.display = 'none'; - pauseButton.parentElement.style.display = ''; + domElements.buttons.restart.hideButton(); + domElements.buttons.pause.showButton(); // Remove all classes from the views document.body.classList = ''; - counterTextView.classList = ''; - secondProgressBar.classList = ''; - infoTextView.classList = ''; - } + domElements.counter.classList = ''; + domElements.progress.classList = ''; + domElements.info.classList = ''; + }; - function durationCountdown () { - counterTextView.classList.remove('red'); - counterTextView.classList.add('primary'); - secondProgressBar.classList.remove('red'); - infoTextView.textContent = text.infoText.active; - return countdown(duration, counterTextView, setProgressBar); - } + const durationCountdown = () => { + domElements.counter.classList.remove('red'); + domElements.counter.classList.add('primary'); + domElements.progress.classList.remove('red'); + domElements.info.textContent = text.info.active; + return countdown(duration, domElements.counter, setProgressBar); + }; - function breakDurationCountdown () { - counterTextView.classList.remove('primary'); - counterTextView.classList.add('red'); - secondProgressBar.classList.add('red'); - infoTextView.textContent = text.infoText.coolDown; - return countdown(breakDuration, counterTextView, () => { - beepAudio.play(); + const breakDurationCountdown = () => { + domElements.counter.classList.remove('primary'); + domElements.counter.classList.add('red'); + domElements.progress.classList.add('red'); + domElements.info.textContent = text.info.coolDown; + return countdown(breakDuration, domElements.counter, () => { + domElements.audio.beep.play(); setProgressBar(); }); - } + }; - function endTimer () { + const endTimer = () => { // Setting end classes - secondProgressBar.classList.add('remove'); - skipTransition(counterTextView, () => - counterTextView.classList.remove('red')); - counterTextView.classList.add('end'); + domElements.progress.classList.add('remove'); + skipTransition(domElements.counter, () => + domElements.counter.classList.remove('red')); + domElements.counter.classList.add('end'); // Setting end text to views - counterTextView.textContent = text.counterText.end; - infoTextView.textContent = text.infoText.complete; + domElements.counter.textContent = text.counter.end; + domElements.info.textContent = text.info.complete; // Setting end visibility for Action Buttons - pauseButton.parentElement.style.display = 'none'; - restartButton.parentElement.style.display = ''; - } + domElements.buttons.pause.hideButton(); + domElements.buttons.restart.showButton(); + }; - async(function* () { + const setRemainingText = (stationsLeft) => { + let stationsLeftText = ''; + // Determine text to be displayed on the stations remaining counter + // The text is based on the number of stations left + if (stationsLeft === 0) { + stationsLeftText = text.remaining.none; + } else if (stationsLeft === 1) { + stationsLeftText = text.remaining.single; + } else { + // Concatenate the word form of the number of stations left with the text + stationsLeftText = toSentenceCase(numberToWords.toWords(stationsLeft)); + stationsLeftText += text.remaining.multiple; + } + // Set the calculated text to the stations remaining counter view + domElements.remaining.textContent = stationsLeftText; + }; + + const countdownAction = async () => { + await durationCountdown(); + await breakDurationCountdown(); + }; + + (async () => { resetTimer(); - // Start the timer: repeat for however many stations there are - for (let i = 0; i < numRepeats; i++) { - yield durationCountdown(); - yield breakDurationCountdown(); + for (let stationsLeft = numRepeats; stationsLeft > 0; stationsLeft -= 1) { + setRemainingText(stationsLeft); + // Since we do not want the loop to continue until the cooldown promise is resolved, + // we wait for the asynchronous code to complete before moving on to the next iteration + await countdownAction(); // eslint-disable-line no-await-in-loop + // This is called here to update the counter text before the loop is broken + setRemainingText(stationsLeft - 1); } endTimer(); - }); + })(); }); diff --git a/app/renderers/pause.js b/app/renderers/pause.js index bccf204..1ee9e07 100644 --- a/app/renderers/pause.js +++ b/app/renderers/pause.js @@ -1,11 +1,9 @@ -const {remote} = require('electron'); +const { remote } = require('electron'); -const resumeButton = document.getElementById('resume'); - -resumeButton.addEventListener('click', () => { - let win = remote.getCurrentWindow(); +document.querySelector('#resume').addEventListener('click', () => { + const win = remote.getCurrentWindow(); // Lose focus before closing setup modal to prevent screen flash win.blur(); // Destroy it directly since this is a non-closeable window win.destroy(); -}); \ No newline at end of file +}); diff --git a/app/renderers/setup.js b/app/renderers/setup.js index bc0abff..fae1d42 100644 --- a/app/renderers/setup.js +++ b/app/renderers/setup.js @@ -1,56 +1,50 @@ -const {ipcRenderer, remote} = require('electron'); +const { ipcRenderer, remote } = require('electron'); const setupForm = document.forms.namedItem('setup'); -const setupFormInputs = Array.from( - setupForm.querySelectorAll('input[type="number"]') -); -const exitButton = document.getElementById('exit'); +const setupFormInputs = Array.from(setupForm.querySelectorAll('input[type="number"]')); -function checkValidInput (value, min, max) { +const checkValidInput = (value, min, max) => // The handling of empty inputs is done in CSS. If the input is not empty and // the value is an empty string, that means the input is invalid. - return value !== '' && + value !== '' && (Number.isSafeInteger(Number(value)) && (min === '' || parseInt(value, 10) >= parseInt(min, 10)) && (max === '' || parseInt(value, 10) <= parseInt(max, 10))); -} -setupFormInputs.forEach(input => { +setupFormInputs.forEach((input) => { input.addEventListener('input', () => { - let valid = checkValidInput(input.value, input.min, input.max); + const valid = checkValidInput(input.value, input.min, input.max); input.classList.toggle('error', !valid); }); }); -setupForm.addEventListener('submit', evt => { +setupForm.addEventListener('submit', (evt) => { evt.preventDefault(); - let valid = setupFormInputs.every(input => { - let invalid = input.classList.contains('error'); - if (invalid) { - // If any of the form inputs contain errors, focus on it - input.focus(); - } - return !invalid; - }); // Return early (don't finish submitting the form) if not all inputs are valid - if (!valid) { + if (setupFormInputs.every((input) => { + const invalid = input.classList.contains('error'); + // If any of the form inputs contain errors, focus on it + if (invalid) input.focus(); + return invalid; + })) { return; } - ipcRenderer.send( - 'setup-timer', + ipcRenderer.send('setup-timer', + // An object is created from the form data + // The names of the form fields are used as keys + // The values of the fields are parsed from the supplied string to an integer Object.assign(...Array.from(new FormData(setupForm)) - .map(([k, v]) => ({ [k]: v }))) - ); + .map(([k, v]) => ({ [k]: parseInt(v, 10) })))); - let win = remote.getCurrentWindow(); + const win = remote.getCurrentWindow(); // Lose focus before closing setup modal to prevent screen flash win.blur(); // Destroy it directly to bypass close event if not closed win.destroy(); }); -exitButton.addEventListener('click', () => ipcRenderer.send('exit')); \ No newline at end of file +document.querySelector('#exit').setAction(() => ipcRenderer.send('exit')); diff --git a/app/styles/base.css b/app/styles/base.css index 704694e..e8f59cb 100644 --- a/app/styles/base.css +++ b/app/styles/base.css @@ -177,86 +177,9 @@ button[type='submit'] svg { margin-left: 0.5rem; } -/* Action Button styles for all pages (would preferably be a Custom Element, but v1 of the API is not currently supported (Electron v1.4.13)) */ - +/* Action Button container positioning */ .action-buttons { position: fixed; bottom: 0; right: 0; } - -.action-buttons div { - display: inline-flex; - flex-direction: column-reverse; - align-items: center; - justify-content: center; -} - -.action-buttons div button { - position: relative; - display: flex; - align-items: center; - justify-content: center; - padding: 0.375rem; - margin: 0.75rem; - margin-top: 0; - outline: none; - border: none; - background-color: transparent; -} - -.action-buttons div button::before { - content: ''; - position: absolute; - z-index: -1; - width: 0; - height: 0; - border-radius: 50%; - background-color: var(--color-grey); - opacity: 0; - transition: all 0.3s var(--curve-standard); - transition-property: width, height, opacity; - /* Since there are not that many buttons on each page, it's fine to give each of them a layer initially instead of dynamically */ - will-change: width, height, opacity; -} - -.action-buttons div button:active::before { - width: 3rem; - height: 3rem; - opacity: 0.3; -} - -.action-buttons div button svg { - width: 2.25rem; - height: 2.25rem; - fill: var(--color-grey); - transition: fill 0.3s var(--curve-standard); - will-change: fill; -} - -.action-buttons div button:focus svg { - fill: var(--color-primary-light); -} - -.action-buttons div button:hover svg { - fill: var(--color-primary); -} - -.action-buttons div button:active svg { - fill: var(--color-primary-dark); -} - -.action-buttons div p { - display: inline-block; - padding: 0.25rem 0.5rem; - font-size: 0.8em; - border-radius: var(--corner-radius); - color: var(--font-color-light); - background-color: var(--font-color-dark); - opacity: 0; - transition: opacity 0.3s var(--curve-sharp); -} - -.action-buttons div button:hover ~ p { - opacity: 0.7; -} diff --git a/app/styles/index.css b/app/styles/index.css index 0e534c7..2ee23f2 100644 --- a/app/styles/index.css +++ b/app/styles/index.css @@ -23,6 +23,11 @@ body.dim::after { background-color: rgba(0, 0, 0, 0.5); } +#remaining { + font-size: 1.25em; + opacity: 0.75; +} + #counter { --color-counter: var(--color-grey); color: var(--color-counter); @@ -65,7 +70,7 @@ body.dim::after { --color-progress: var(--color-primary); background-color: var(--color-progress); width: 0; - height: 0.25rem; + height: 0.3125rem; margin: auto; margin-bottom: 1.5rem; border-radius: var(--corner-radius); diff --git a/app/views/index.html b/app/views/index.html index a64c6ea..f8d5b8d 100644 --- a/app/views/index.html +++ b/app/views/index.html @@ -1,71 +1,31 @@ - + Station Timer + + +
+

- -
- -
- -

Pause

-
- - -
- -

Restart

-
- -
- -

Mute

-
- -
- -

Unmute

-
- -
- -

Exit

-
- + + + + +