diff options
Diffstat (limited to 'app/client')
| -rw-r--r-- | app/client/common/group.component.js | 10 | ||||
| -rw-r--r-- | app/client/common/header.component.js | 14 | ||||
| -rw-r--r-- | app/client/dashboard/actions.js | 1 | ||||
| -rw-r--r-- | app/client/dashboard/dashboard.component.js | 71 | ||||
| -rw-r--r-- | app/client/dashboard/dashboard.reducer.js | 16 | ||||
| -rw-r--r-- | app/client/dashboard/dashboardHeader.component.js | 39 | ||||
| -rw-r--r-- | app/client/dashboard/filelist.component.js | 46 | ||||
| -rw-r--r-- | app/client/dashboard/gallery.component.js | 33 | ||||
| -rw-r--r-- | app/client/dashboard/index.js | 2 | ||||
| -rw-r--r-- | app/client/dashboard/tasklist.component.js | 45 | ||||
| -rw-r--r-- | app/client/index.jsx | 8 | ||||
| -rw-r--r-- | app/client/live/actions.js | 21 | ||||
| -rw-r--r-- | app/client/live/player.js | 21 | ||||
| -rw-r--r-- | app/client/live/reducer.js | 28 | ||||
| -rw-r--r-- | app/client/pix2pix/index.js (renamed from app/client/live/index.js) | 8 | ||||
| -rw-r--r-- | app/client/socket.js | 9 | ||||
| -rw-r--r-- | app/client/store.js | 4 | ||||
| -rw-r--r-- | app/client/system/system.reducer.js | 106 | ||||
| -rw-r--r-- | app/client/types.js | 31 | ||||
| -rw-r--r-- | app/client/util.js | 6 |
20 files changed, 475 insertions, 44 deletions
diff --git a/app/client/common/group.component.js b/app/client/common/group.component.js new file mode 100644 index 0000000..5dc9ddf --- /dev/null +++ b/app/client/common/group.component.js @@ -0,0 +1,10 @@ +import { h, Component } from 'preact' + +export default function Group (props){ + return ( + <div className='group'> + <h3>{this.props.title}</h3> + {this.props.children} + </div> + ) +} diff --git a/app/client/common/header.component.js b/app/client/common/header.component.js index b5a484e..1e27856 100644 --- a/app/client/common/header.component.js +++ b/app/client/common/header.component.js @@ -1,10 +1,24 @@ import { h, Component } from 'preact' +import { Link } from 'react-router-dom'; import { connect } from 'react-redux' function Header(props) { + const tools = "pix2pix samplernn style_transfer_video style_transfer_audio".split(" ").map((s,i) => { + return <option value={s}>{s}</option> + }) return ( <header> <b>live cortex</b> + <span> + <select> + {tools} + </select> + </span> + <span><Link to="/dashboard">dashboard</Link></span> + <span>checkpoints</span> + <span>datasets</span> + <span>results</span> + <span>live</span> <span>{props.fps} fps</span> </header> ) diff --git a/app/client/dashboard/actions.js b/app/client/dashboard/actions.js new file mode 100644 index 0000000..01c0a96 --- /dev/null +++ b/app/client/dashboard/actions.js @@ -0,0 +1 @@ +import types from '../types' diff --git a/app/client/dashboard/dashboard.component.js b/app/client/dashboard/dashboard.component.js new file mode 100644 index 0000000..6db42ae --- /dev/null +++ b/app/client/dashboard/dashboard.component.js @@ -0,0 +1,71 @@ +import { h, Component } from 'preact' +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' + +import Player from '../common/player.component' +import Group from '../common/group.component' +import Slider from '../common/slider.component' +import Select from '../common/select.component' +import Button from '../common/button.component' + +import DashboardHeader from './dashboardheader.component' +import TaskList from './tasklist.component' +import FileList from './filelist.component' +import Gallery from './gallery.component' + +import * as liveActions from './actions' + +class Dashboard extends Component { + constructor(props){ + super() + } + componentWillUpdate(nextProps) { + // if (nextProps.opt.checkpoint_name && nextProps.opt.checkpoint_name !== this.props.opt.checkpoint_name) { + // this.props.actions.list_epochs(nextProps.opt.checkpoint_name) + // } + } + render(){ + const { tasks, files, images, site } = this.props + return ( + <div className='dashboard'> + <DashboardHeader /> + <div className='params'> + <div className='column'> + <Group title='Completed Tasks'> + <TaskList tasks={tasks} /> + </Group> + <Group title='Upcoming Tasks'> + <TaskList tasks={tasks} /> + </Group> + </div> + <div className='column'> + <Group title='Your Datasets'> + <FileList files={files} /> + </Group> + <Group title='Results'> + <FileList files={files} /> + </Group> + <Group title='Audio Player'> + <FileList files={files} /> + </Group> + </div> + </div> + <div> + <Gallery images={images} /> + </div> + </div> + ) + } +} +const mapStateToProps = state => ({ + site: state.system.site, + images: state.system.images, + tasks: state.system.tasks, + files: state.system.files +}) + +const mapDispatchToProps = (dispatch, ownProps) => ({ + actions: bindActionCreators(liveActions, dispatch) +}) + +export default connect(mapStateToProps, mapDispatchToProps)(Dashboard) diff --git a/app/client/dashboard/dashboard.reducer.js b/app/client/dashboard/dashboard.reducer.js new file mode 100644 index 0000000..c5b3d4a --- /dev/null +++ b/app/client/dashboard/dashboard.reducer.js @@ -0,0 +1,16 @@ +import moment from 'moment' +let FileSaver = require('file-saver') + +const dashboardInitialState = { + loading: false, + error: null, +} + +const dashboardReducer = (state = dashboardInitialState, action) => { + switch(action.type) { + default: + return state + } +} + +export default dashboardReducer diff --git a/app/client/dashboard/dashboardHeader.component.js b/app/client/dashboard/dashboardHeader.component.js new file mode 100644 index 0000000..492dfd8 --- /dev/null +++ b/app/client/dashboard/dashboardHeader.component.js @@ -0,0 +1,39 @@ +import { h, Component } from 'preact' +import { connect } from 'react-redux' +import { bindActionCreators } from 'redux' +import * as liveActions from '../live/actions' + +import * as util from '../util' + +class DashboardHeader extends Component { + constructor(props){ + super(props) + this.handleClick = this.handleClick.bind(this) + } + handleClick(e){ + this.props.onClick && this.props.onClick() + } + render() { + const { currentTask, site } = this.props + const eta = ((currentTask.epochs - currentTask.epoch) * 180 / 60) + " minutes" + return ( + <div class='dashboardHeader heading'> + <h3>{site.name}</h3> + Currently {util.gerund(currentTask.activity)} {currentTask.library} on {currentTask.dataset}<br/> + Epoch: {currentTask.epoch} / {currentTask.epochs}, ETA {eta}<br/> + <br/> + Want to play live? <button>Pause at the end of this epoch</button> + </div> + ) + } +} + +const mapStateToProps = state => ({ + currentTask: state.system.currentTask, + site: state.system.site, +}) + +const mapDispatchToProps = (dispatch, ownProps) => ({ +}) + +export default connect(mapStateToProps, mapDispatchToProps)(DashboardHeader) diff --git a/app/client/dashboard/filelist.component.js b/app/client/dashboard/filelist.component.js new file mode 100644 index 0000000..2833ec8 --- /dev/null +++ b/app/client/dashboard/filelist.component.js @@ -0,0 +1,46 @@ +import { h, Component } from 'preact' +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' +import * as util from '../util' + +class FileList extends Component { + constructor(props){ + super() + } + render(){ + const { files } = this.props + let time = 0 + const fileList = files.map(file => { + const eta = (time + (file.epochs) * 180 / 60) + " min." + time += (file.epochs) * 180 / 60 + let dataset_type, dataset_name + if (file.dataset.indexOf('/') !== -1) { + [dataset_type, dataset_name] = file.dataset.split('/') + } else { + dataset_name = file.dataset + } + return ( + <div class='row'> + <div class='activity'>{file.activity} {file.library} {dataset_type}</div> + <div class='dataset'>{dataset_name}</div> + <div class='epochs'>{file.epochs} ep.</div> + <div class='eta'>{eta}</div> + </div> + ) + }) + return ( + <div class='filelist rows'> + {fileList} + </div> + ) + } +} + +const mapStateToProps = state => ({ +}) + +const mapDispatchToProps = (dispatch, ownProps) => ({ + // actions: bindActionCreators(liveActions, dispatch) +}) + +export default connect(mapStateToProps, mapDispatchToProps)(FileList) diff --git a/app/client/dashboard/gallery.component.js b/app/client/dashboard/gallery.component.js new file mode 100644 index 0000000..0e1f44d --- /dev/null +++ b/app/client/dashboard/gallery.component.js @@ -0,0 +1,33 @@ +import { h, Component } from 'preact' +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' + +class Gallery extends Component { + constructor(props){ + super() + } + render(){ + const { title, images } = this.props + images.push(images[0]) + const imageList = images.map(image => { + return ( + <div> + <img src={image.url} /> + </div> + ) + }) + return ( + <div class='gallery'> + {imageList} + </div> + ) + } +} +const mapStateToProps = state => ({ +}) + +const mapDispatchToProps = (dispatch, ownProps) => ({ + // actions: bindActionCreators(liveActions, dispatch) +}) + +export default connect(mapStateToProps, mapDispatchToProps)(Gallery) diff --git a/app/client/dashboard/index.js b/app/client/dashboard/index.js new file mode 100644 index 0000000..29b1ecf --- /dev/null +++ b/app/client/dashboard/index.js @@ -0,0 +1,2 @@ +export default { +}
\ No newline at end of file diff --git a/app/client/dashboard/tasklist.component.js b/app/client/dashboard/tasklist.component.js new file mode 100644 index 0000000..fa002de --- /dev/null +++ b/app/client/dashboard/tasklist.component.js @@ -0,0 +1,45 @@ +import { h, Component } from 'preact' +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' + +class TaskList extends Component { + constructor(props){ + super() + } + render(){ + const { title, tasks } = this.props + let time = 0 + const taskList = tasks.map(task => { + const eta = (time + (task.epochs) * 180 / 60) + " min." + time += (task.epochs) * 180 / 60 + let dataset_type, dataset_name + if (task.dataset.indexOf('/') !== -1) { + [dataset_type, dataset_name] = task.dataset.split('/') + } else { + dataset_name = task.dataset + } + return ( + <div class='row'> + <div class='activity'>{task.activity} {task.library} {dataset_type}</div> + <div class='dataset'>{dataset_name}</div> + <div class='epochs'>{task.epochs} ep.</div> + <div class='eta'>{eta}</div> + </div> + ) + }) + return ( + <div class='taskList rows'> + {taskList} + </div> + ) + } +} + +const mapStateToProps = state => ({ +}) + +const mapDispatchToProps = (dispatch, ownProps) => ({ + // actions: bindActionCreators(liveActions, dispatch) +}) + +export default connect(mapStateToProps, mapDispatchToProps)(TaskList) diff --git a/app/client/index.jsx b/app/client/index.jsx index d30c73f..a084306 100644 --- a/app/client/index.jsx +++ b/app/client/index.jsx @@ -7,15 +7,17 @@ import { store, history } from './store' import socket from './socket' import Header from './common/header.component' -import Live from './live' +import Dashboard from './dashboard/dashboard.component' +import LivePix2Pix from './pix2pix' const app = ( <Provider store={store}> <BrowserRouter> <div> + <Route path='/' component={Dashboard} /> + <Route path='/live/' component={LivePix2Pix} /> + <Route path='/dashboard/' component={Dashboard} /> <Header /> - <Route path='/' component={Live} /> - <Route path='/live/' component={Live} /> </div> </BrowserRouter> </Provider> diff --git a/app/client/live/actions.js b/app/client/live/actions.js index c9927b3..e63854e 100644 --- a/app/client/live/actions.js +++ b/app/client/live/actions.js @@ -1,52 +1,53 @@ import * as socket from '../socket' +import types from '../types' export const get_params = () => { socket.get_params() - return { type: 'GET_PARAMS', } + return { type: types.player.get_params, } } export const set_param = (key, value) => { console.log('set param', key, value) socket.set_param(key, value) - return { type: 'SET_PARAM', key, value, } + return { type: types.player.set_param, key, value, } } export const list_checkpoints = () => { socket.list_checkpoints() - return { type: 'LOADING_CHECKPOINTS', } + return { type: types.player.loading_checkpoints, } } export const list_epochs = (path) => { socket.list_epochs(path) - return { type: 'LOADING_EPOCHS', } + return { type: types.player.loading_epochs, } } export const list_sequences = () => { socket.list_sequences() - return { type: 'LOADING_SEQUENCES', } + return { type: types.player.loading_sequences } } export const load_sequence = (sequence) => { socket.load_sequence(sequence) - return { type: 'LOADING_SEQUENCE', } + return { type: types.player.loading_sequence, } } export const load_epoch = (checkpoint, epoch) => { socket.load_epoch(checkpoint, epoch) - return { type: 'LOADING_CHECKPOINT', } + return { type: types.player.loading_checkpoint, } } export const seek = (frame) => { socket.seek(frame) - return { type: 'SEEKING', } + return { type: types.player.seeking, } } export const pause = (frame) => { socket.pause(pause) - return { type: 'PAUSING', } + return { type: types.player.pausing, } } export const play = (frame) => { socket.play() - return { type: 'PLAYING', } + return { type: types.player.playing, } } diff --git a/app/client/live/player.js b/app/client/live/player.js index b39d717..ac5f0c8 100644 --- a/app/client/live/player.js +++ b/app/client/live/player.js @@ -1,5 +1,6 @@ import { store } from '../store' import Whammy from './whammy' +import types from '../types' let fps = 0, last_frame let recording = false, saving = false @@ -9,7 +10,7 @@ export function startRecording(){ videoWriter = new Whammy.Video(10) recording = true store.dispatch({ - type: 'START_RECORDING', + type: types.player.start_recording, }) } @@ -17,12 +18,12 @@ export function stopRecording(){ if (!recording) return recording = false store.dispatch({ - type: 'SAVING_VIDEO', + type: types.player.saving_video, }) videoWriter.compile(false, function(blob){ - console.log(blob) + // console.log(blob) store.dispatch({ - type: 'SAVE_VIDEO', + type: types.player.save_video, blob: blob, }) }) @@ -37,23 +38,25 @@ export function onFrame (data) { const url = URL.createObjectURL(blob) const img = new Image () img.onload = function() { + img.onload = null last_frame = data.meta URL.revokeObjectURL(url) - const canvas = document.querySelector('.player canvas') + let canvas = document.querySelector('.player canvas') + if (! canvas) return console.error('no canvas for frame') const ctx = canvas.getContext('2d') ctx.drawImage(img, 0, 0, canvas.width, canvas.height) if (recording) { console.log('record frame') videoWriter.add(canvas) store.dispatch({ - type: 'ADD_RECORD_FRAME', + type: types.player.add_record_frame, }) } if (saving) { saving = false canvas.toBlob(function(blob) { store.dispatch({ - type: 'SAVE_FRAME', + type: types.player.save_frame, blob: blob, }) }) @@ -65,11 +68,11 @@ export function onFrame (data) { setInterval(() => { store.dispatch({ - type: 'SET_FPS', + type: types.player.set_fps, fps: fps, }) store.dispatch({ - type: 'CURRENT_FRAME', + type: types.player.current_frame, meta: last_frame, }) fps = 0 diff --git a/app/client/live/reducer.js b/app/client/live/reducer.js index 8fa6edd..60bcb41 100644 --- a/app/client/live/reducer.js +++ b/app/client/live/reducer.js @@ -1,6 +1,6 @@ -import { combineReducers } from 'redux' import moment from 'moment' -let FileSaver = require('file-saver') +import FileSaver from 'file-saver' +import types from '../types' const liveInitialState = { loading: false, @@ -17,7 +17,7 @@ const liveReducer = (state = liveInitialState, action) => { let results; switch(action.type) { - case 'LOAD_PARAMS': + case types.player.load_params: if (! action.opt || ! Object.keys(action.opt).length) { return state } @@ -28,7 +28,7 @@ const liveReducer = (state = liveInitialState, action) => { opt: action.opt, } - case 'SET_PARAM': + case types.player.set_param: return { ...state, opt: { @@ -37,14 +37,14 @@ const liveReducer = (state = liveInitialState, action) => { } } - case 'LIST_CHECKPOINTS': + case types.player.list_checkpoints: return { ...state, checkpoints: action.checkpoints, epochs: [], } - case 'LIST_EPOCHS': + case types.player.list_epochs: return { ...state, epochs: (action.epochs || []).map(a => [ a == 'latest' ? Infinity : a, a ]) @@ -52,25 +52,25 @@ const liveReducer = (state = liveInitialState, action) => { .map(a => a[1]) } - case 'LIST_SEQUENCES': + case types.player.list_sequences: return { ...state, sequences: action.sequences, } - case 'SET_FPS': + case types.player.set_fps: return { ...state, fps: action.fps, } - case 'CURRENT_FRAME': + case types.player.current_frame: return action.meta ? { ...state, frame: action.meta } : state - case 'START_RECORDING': + case types.player.start_recording: return { ...state, opt: { @@ -78,7 +78,7 @@ const liveReducer = (state = liveInitialState, action) => { recording: true, } } - case 'ADD_RECORD_FRAME': + case types.player.add_record_frame: return { ...state, opt: { @@ -87,7 +87,7 @@ const liveReducer = (state = liveInitialState, action) => { } } - case 'SAVE_FRAME': + case types.player.save_frame: FileSaver.saveAs( action.blob, state.opt.checkpoint_name + "_" + @@ -96,7 +96,7 @@ const liveReducer = (state = liveInitialState, action) => { ) return state - case 'SAVING_VIDEO': + case types.player.saving_video: return { ...state, opt: { @@ -104,7 +104,7 @@ const liveReducer = (state = liveInitialState, action) => { savingVideo: true, } } - case 'SAVE_VIDEO': + case types.player.save_video: FileSaver.saveAs( action.blob, state.opt.checkpoint_name + "_" + diff --git a/app/client/live/index.js b/app/client/pix2pix/index.js index 94af289..9d41fbc 100644 --- a/app/client/live/index.js +++ b/app/client/pix2pix/index.js @@ -8,11 +8,11 @@ import Slider from '../common/slider.component' import Select from '../common/select.component' import Button from '../common/button.component' -import { startRecording, stopRecording, saveFrame } from './player' +import { startRecording, stopRecording, saveFrame } from '../live/player' -import * as liveActions from './actions' +import * as liveActions from '../live/actions' -class App extends Component { +class LivePix2Pix extends Component { constructor(props){ super() props.actions.get_params() @@ -249,4 +249,4 @@ const mapDispatchToProps = (dispatch, ownProps) => ({ actions: bindActionCreators(liveActions, dispatch) }) -export default connect(mapStateToProps, mapDispatchToProps)(App) +export default connect(mapStateToProps, mapDispatchToProps)(LivePix2Pix) diff --git a/app/client/socket.js b/app/client/socket.js index a2a745e..ea6f380 100644 --- a/app/client/socket.js +++ b/app/client/socket.js @@ -17,25 +17,25 @@ socket.on('res', (data) => { break case 'get_params': store.dispatch({ - type: 'LOAD_PARAMS', + type: types.socket.load_params, opt: data.res, }) break case 'list_checkpoints': store.dispatch({ - type: 'LIST_CHECKPOINTS', + type: types.socket.list_checkpoints, checkpoints: data.res, }) break case 'list_sequences': store.dispatch({ - type: 'LIST_SEQUENCES', + type: types.socket.list_sequences, sequences: data.res, }) break case 'list_epochs': store.dispatch({ - type: 'LIST_EPOCHS', + type: types.socket.list_epochs, epochs: data.res, }) break @@ -49,6 +49,7 @@ socket.on('frame', player.onFrame) socket.on('status', (data) => { console.log('got status', data.key, data.value) + store.dispatch({ type: types.socket.status }) switch (data.key) { case 'processing': store.dispatch({ diff --git a/app/client/store.js b/app/client/store.js index 863161d..600e53c 100644 --- a/app/client/store.js +++ b/app/client/store.js @@ -6,9 +6,13 @@ import createHistory from 'history/createBrowserHistory' import { routerReducer } from 'react-router-redux' // import navReducer from './nav.reducer' +import systemReducer from './system/system.reducer' +import dashboardReducer from './dashboard/dashboard.reducer' import liveReducer from './live/reducer' const appReducer = combineReducers({ + system: systemReducer, + dashboard: dashboardReducer, live: liveReducer, router: routerReducer, }) diff --git a/app/client/system/system.reducer.js b/app/client/system/system.reducer.js new file mode 100644 index 0000000..bc19fd1 --- /dev/null +++ b/app/client/system/system.reducer.js @@ -0,0 +1,106 @@ +import moment from 'moment' +let FileSaver = require('file-saver') + +const systemInitialState = { + loading: false, + error: null, + + site: { + name: 'Lens Cortex', + }, + currentTask: { + id: 1072, + activity: 'train', + library: 'pix2pix', + dataset: 'video/woods_final', + epoch: 87, + epochs: 100, + }, + images: [ + { + url: 'https://s3.amazonaws.com/i.asdf.us/bucky/data/4282/woodscaled_4_true_20180521_2125.png', + }, + { + url: 'https://s3.amazonaws.com/i.asdf.us/bucky/data/4282/woodscaled_4_true_20180521_2146%20(1).png', + }, + { + url: 'https://s3.amazonaws.com/i.asdf.us/bucky/data/4282/woodscaled_4_true_20180521_2149.png', + }, + { + url: 'https://s3.amazonaws.com/i.asdf.us/bucky/data/4282/woodscaled_4_true_20180521_2150.png', + }, + ], + tasks: [ + { + id: 1073, + activity: 'train', + library: 'pix2pix', + dataset: 'video/woods_green', + epochs: 100, + }, + { + id: 1073, + activity: 'train', + library: 'samplernn', + dataset: 'bobby_brown_-_every_little_step', + epochs: 6, + }, + { + id: 1073, + activity: 'train', + library: 'pix2pix', + checkpoint: 'lyra_voice_layers', + dataset: 'audio/lyra_improv', + epochs: 30, + }, + { + id: 1073, + activity: 'train', + library: 'pix2pix', + checkpoint: 'lyra_melody_lines', + dataset: 'audio/lyra_improv', + epochs: 30, + }, + { + id: 1073, + activity: 'train', + library: 'pix2pix', + checkpoint: 'ensemble_chords', + dataset: 'audio/lyra_improv', + epochs: 30, + }, + { + id: 1073, + activity: 'generate', + library: 'samplernn', + dataset: 'coccoglass3', + opt: { time: 5, count: 6 }, + }, + { + id: 1073, + activity: 'train', + library: 'pix2pix', + dataset: 'video/woods_green', + epochs: 100, + }, + { + id: 1073, + activity: 'train', + library: 'samplernn', + dataset: 'bobby_brown_-_every_little_step', + epochs: 6, + }, + ], + files: [ + { id: 2, library: 'samplernn', checkpoint: 'jwcglassbeat', dataset: 'jwcglassbeat', epoch: 18, duration: 30, batch_size: 5, filename: 'jwcglassbeat-ep18.mp3', size: 3 * 1024 * 1024, date: Date.now(), opt: "{}", } + ] +} + +const systemReducer = (state = systemInitialState, action) => { + switch(action.type) { + default: + return state + } +} + +export default systemReducer diff --git a/app/client/types.js b/app/client/types.js new file mode 100644 index 0000000..782a225 --- /dev/null +++ b/app/client/types.js @@ -0,0 +1,31 @@ +export default { + socket: { + load_params: 'LOAD_PARAMS', + list_sequences: 'LIST_SEQUENCES', + list_epochs: 'LIST_EPOCHS', + }, + player: { + get_params: 'GET_PARAMS', + set_param: 'SET_PARAM', + + loading_checkpoints: 'LOADING_CHECKPOINTS', + list_checkpoints: 'LIST_CHECKPOINTS', + + loading_sequences: 'LOADING_SEQUENCES', + load_sequence: 'LOAD_SEQUENCE', + + loading_epochs: 'LOADING_EPOCHS', + load_epoch: 'LOAD_EPOCH', + + set_fps: 'SET_FPS', + seeking: 'SEEKING', + pausing: 'PAUSING', + playing: 'PLAYING', + current_frame: 'CURRENT_FRAME', + start_recording: 'START_RECORDING', + add_record_frame: 'ADD_RECORD_FRAME', + save_frame: 'SAVE_FRAME', + saving_video: 'SAVING_VIDEO', + save_video: 'SAVE_VIDEO', + } +}
\ No newline at end of file diff --git a/app/client/util.js b/app/client/util.js new file mode 100644 index 0000000..db9fa8c --- /dev/null +++ b/app/client/util.js @@ -0,0 +1,6 @@ +export function timeInSeconds(n){ + return (n / 10).toFixed(1) + ' s.' +} +export function gerund(s){ + return s.replace(/e?$/, 'ing') +} |
