From 47bf2bf28e1f36512b18a844503209eda250a6b2 Mon Sep 17 00:00:00 2001 From: Mike Wyatt Date: Sat, 14 Jul 2018 21:49:39 -0300 Subject: [PATCH 01/16] remove all .js files --- webapp/.eslintrc | 1 - webapp/src/App.js | 23 ----- webapp/src/IconMenu.js | 61 ------------ webapp/src/MappingEntry.js | 101 ------------------- webapp/src/MappingList.js | 158 ------------------------------ webapp/src/Status.js | 43 -------- webapp/src/UnmanagedTextField.js | 51 ---------- webapp/src/common.js | 34 ------- webapp/src/config.js | 3 - webapp/src/index.js | 27 ----- webapp/src/mappings/actions.js | 84 ---------------- webapp/src/mappings/reducer.js | 37 ------- webapp/src/redux/createReducer.js | 9 -- webapp/src/redux/createStore.js | 32 ------ webapp/src/redux/rootReducer.js | 11 --- webapp/src/status/actions.js | 52 ---------- webapp/src/status/reducer.js | 32 ------ 17 files changed, 759 deletions(-) delete mode 120000 webapp/.eslintrc delete mode 100644 webapp/src/App.js delete mode 100644 webapp/src/IconMenu.js delete mode 100644 webapp/src/MappingEntry.js delete mode 100644 webapp/src/MappingList.js delete mode 100644 webapp/src/Status.js delete mode 100644 webapp/src/UnmanagedTextField.js delete mode 100644 webapp/src/common.js delete mode 100644 webapp/src/config.js delete mode 100644 webapp/src/index.js delete mode 100644 webapp/src/mappings/actions.js delete mode 100644 webapp/src/mappings/reducer.js delete mode 100644 webapp/src/redux/createReducer.js delete mode 100644 webapp/src/redux/createStore.js delete mode 100644 webapp/src/redux/rootReducer.js delete mode 100644 webapp/src/status/actions.js delete mode 100644 webapp/src/status/reducer.js diff --git a/webapp/.eslintrc b/webapp/.eslintrc deleted file mode 120000 index f8bcf76..0000000 --- a/webapp/.eslintrc +++ /dev/null @@ -1 +0,0 @@ -/Users/mike/Work/xarcade-xinput/webapp/node_modules/react-scripts/eslintrc \ No newline at end of file diff --git a/webapp/src/App.js b/webapp/src/App.js deleted file mode 100644 index 444445c..0000000 --- a/webapp/src/App.js +++ /dev/null @@ -1,23 +0,0 @@ -import { - React, - PureComponent, -} from './common' - -import Status from './Status' -import MappingList from './MappingList' - -class App extends PureComponent { - state = { - isControllerRunning: false, - isKeyboardRunning: false, - } - - render() { - return
- - -
- } -} - -export default App diff --git a/webapp/src/IconMenu.js b/webapp/src/IconMenu.js deleted file mode 100644 index e9a4171..0000000 --- a/webapp/src/IconMenu.js +++ /dev/null @@ -1,61 +0,0 @@ -import React, { PureComponent } from 'react' -import IconButton from 'material-ui/IconButton'; -import { - Menu, -} from 'material-ui/Menu'; - -export default class IconMenu extends PureComponent { - state = { - menuAnchor: null, - isMenuOpen: false, - } - - componentWillUnmount () { - if (this._closeTimeout) { - clearTimeout(this._closeTimeout) - } - } - - render () { - const { - icon, - children, - ...props, - } = this.props - - return
- {icon} - - {children} - -
- } - - openMenu = (event) => { - this.setState({ - menuAnchor: event.currentTarget, - isMenuOpen: true, - }) - } - - closeMenu = () => { - this._closeTimeout = null - this.setState({ - menuAnchor: null, - isMenuOpen: false, - }) - } - - closeMenuWithDelay = () => { - if (this._closeTimeout) { - clearTimeout(this._closeTimeout) - } - - this._closeTimeout = setTimeout(this.closeMenu, 200) - } -} diff --git a/webapp/src/MappingEntry.js b/webapp/src/MappingEntry.js deleted file mode 100644 index ff744f2..0000000 --- a/webapp/src/MappingEntry.js +++ /dev/null @@ -1,101 +0,0 @@ -import { - React, - PureComponent, - ListItem, - ListItemText, - ListItemSecondaryAction, - ListItemIcon, - MenuItem, - Icon, - IconMenu, - connect, -} from './common' - -import * as actions from './mappings/actions' - -class MappingEntry extends PureComponent { - menu = null - - render () { - let icon = null - - if (this.props.isActive) { - icon = - check_circle - - } - - return this.menuIcon = x}> - {icon} - - - this.menu = x}> - - - check_circle - - Make Active - - - - - edit - - Edit - - - - - label - - Rename - - - - - delete_forever - - Delete - - - - - } - - makeActive = () => { - this.menu.closeMenuWithDelay() - - this.props.setCurrent(this.props.name) - .then(this.props.refresh) - } - - startEditing = () => { - this.menu.closeMenuWithDelay() - - this.props.startEditing(this.props.name) - } - - requestDelete = () => { - if (!confirm(`Really delete ${this.props.name}? This cannot be undone.`)) { - return - } - - this.menu.closeMenuWithDelay() - this.props.deleteMapping(this.props.name) - .then(this.props.refresh) - } - - startRename = () => { - const newName = prompt(`New name for ${this.props.name}`, this.props.name) - - if (newName == null) { - return - } - - this.menu.closeMenuWithDelay() - this.props.renameMapping(this.props.name, newName) - .then(this.props.refresh) - } -} - -export default connect(null, actions)(MappingEntry) diff --git a/webapp/src/MappingList.js b/webapp/src/MappingList.js deleted file mode 100644 index 66ed60a..0000000 --- a/webapp/src/MappingList.js +++ /dev/null @@ -1,158 +0,0 @@ -import AceEditor from 'react-ace' -import 'brace/mode/json' -import 'brace/theme/tomorrow_night_eighties' - -import { - React, - PureComponent, - Button, - Icon, - List, - ListSubheader, - Dialog, - DialogActions, - DialogTitle, - DialogContent, - DialogContentText, - UnmanagedTextField, - Text, - connect, -} from './common' - -import MappingEntry from './MappingEntry' - -import * as actions from './mappings/actions' - -class MappingList extends PureComponent { - componentDidMount () { - this.props.refresh() - } - - render () { - const children = this.props.mappingNames.map(x => { - const isActive = x === this.props.currentMapping - return - }) - - return - - Mappings - {children} - - } -} - -class MappingEditor extends PureComponent { - aceEditor = null - renameInput = null - - state = { - isOpen: false, - isRenameDialogOpen: false, - } - - componentWillReceiveProps (nextProps, nextState) { - if (nextProps.editingStartedAt !== this.props.editingStartedAt) { - this.setState({ isOpen: true }) - } - } - - render () { - return { - // HACK Reset overflow / padding because material-ui ain't - setTimeout(() => { - document.body.style.overflow = '' - document.body.style.paddingRight = '' - }, 300) - }} - > - - - Edit "{this.props.currentEditing}" - - - - {this.renderRenameDialog()} - - - - this.aceEditor = x} - /> - - - - - - - } - - renderRenameDialog () { - const defaultName = `${this.props.currentEditing} - ${Math.random().toString(16).substr(2)}` - - return - - Enter a new name - - - this.renameInput = x} - /> - - - - - - - } - - handleClose = () => { - this.setState({ isOpen: false }) - } - - handleRenameDialogClose = () => { - this.setState({ isRenameDialogOpen: false }) - } - - openRenameDialog = () => { - this.setState({ isRenameDialogOpen: true }) - } - - save = () => { - this._save() - } - - saveWithRename = () => { - this._save(this.renameInput.TextField.props.value) - } - - _save = (name = this.props.currentEditing, mapping = this.aceEditor.editor.getValue()) => { - this.props.saveMapping(name, mapping) - .then(this.props.refresh) - .then(this.handleRenameDialogClose) - .then(this.handleClose) - } -} - -MappingEditor = connect(state => ({ - editingStartedAt: state.mappings.editingStartedAt, - currentEditing: state.mappings.currentEditing, - mapping: state.mappings.currentEditing && state.mappings.all[state.mappings.currentEditing], -}), actions)(MappingEditor) - -export default connect(state => ({ - mappingNames: state.mappings.mappingNames, - currentMapping: state.mappings.currentMapping, -}), actions)(MappingList) diff --git a/webapp/src/Status.js b/webapp/src/Status.js deleted file mode 100644 index 4765471..0000000 --- a/webapp/src/Status.js +++ /dev/null @@ -1,43 +0,0 @@ -import { - React, - PureComponent, - ListSubheader, - LabelSwitch, - Button, - connect, -} from './common' - -import * as actions from './status/actions' - -class Status extends PureComponent { - componentDidMount () { - this.props.refresh() - } - - render () { - const isRunning = this.props.isKeyboardRunning && this.props.isControllerRunning - let heading = isRunning - ? `Running` - : 'Not Running' - - if (this.props.hostname) { - heading = `${heading} on ${this.props.hostname}` - } - - return
- Status: {heading} - this.props.setAll(shouldEnable)} - /> -
- -

-
- } -} - -export default connect((state) => ({ - ...state.status, -}), actions)(Status) diff --git a/webapp/src/UnmanagedTextField.js b/webapp/src/UnmanagedTextField.js deleted file mode 100644 index 048a544..0000000 --- a/webapp/src/UnmanagedTextField.js +++ /dev/null @@ -1,51 +0,0 @@ -import React, { PureComponent } from 'react' -import TextField from 'material-ui/TextField' - -export default class UnmanagedTextField extends PureComponent { - TextField = null - - static defaultProps = { - onChange () {}, - } - - state = { - value: null, - } - - /* - componentDidMount () { - this.setState({ value: this.props.defaultValue || '' }) - } - */ - - /* - componentWillReceiveProps (nextProps) { - if (nextProps.defaultValue !== this.props.defaultValue) { - this.setState({ value: null }) - } - } - */ - - render () { - const { - defaultValue, - ...props, - } = this.props - - const value = this.state.value !== null - ? this.state.value - : defaultValue - - return this.TextField = x} - /> - } - - handleChange = (event) => { - this.setState({ value: event.target.value || '' }) - this.props.onChange(event) - } -} diff --git a/webapp/src/common.js b/webapp/src/common.js deleted file mode 100644 index 174007a..0000000 --- a/webapp/src/common.js +++ /dev/null @@ -1,34 +0,0 @@ -export { default as React, PureComponent } from 'react' -export { connect } from 'react-redux' - -export { default as Switch, LabelSwitch } from 'material-ui/Switch' -export { default as Button } from 'material-ui/Button' -export { default as TextField } from 'material-ui/TextField' -export { default as Icon } from 'material-ui/Icon' -export { default as IconButton } from 'material-ui/IconButton' -export { default as Text } from 'material-ui/Text' - -export { - Dialog, - DialogActions, - DialogTitle, - DialogContent, - DialogContentText, -} from 'material-ui/Dialog' - -export { - List, - ListSubheader, - ListItem, - ListItemText, - ListItemSecondaryAction, - ListItemIcon, -} from 'material-ui/List' - -export { - Menu, - MenuItem, -} from 'material-ui/Menu' - -export { default as IconMenu } from './IconMenu' -export { default as UnmanagedTextField } from './UnmanagedTextField' diff --git a/webapp/src/config.js b/webapp/src/config.js deleted file mode 100644 index 8df2e91..0000000 --- a/webapp/src/config.js +++ /dev/null @@ -1,3 +0,0 @@ -const queryParams = new URLSearchParams(window.location.search) - -export const API_URL = queryParams.get('API_URL') || '' diff --git a/webapp/src/index.js b/webapp/src/index.js deleted file mode 100644 index d9157e4..0000000 --- a/webapp/src/index.js +++ /dev/null @@ -1,27 +0,0 @@ -import injectTapEventPlugin from 'react-tap-event-plugin' - -// Needed for onTouchTap -// http://stackoverflow.com/a/34015469/988941 -injectTapEventPlugin() - -import React from 'react' -import ReactDOM from 'react-dom' -import MuiThemeProvider from 'material-ui/styles/MuiThemeProvider' -import { Provider } from 'react-redux' - -import App from './App' -import createStore from './redux/createStore' - -//import 'bootstrap/dist/css/bootstrap.css' -import './index.css' - -const store = createStore({}) - -ReactDOM.render( - - - - - , - document.getElementById('root') -) diff --git a/webapp/src/mappings/actions.js b/webapp/src/mappings/actions.js deleted file mode 100644 index 967b02d..0000000 --- a/webapp/src/mappings/actions.js +++ /dev/null @@ -1,84 +0,0 @@ -import { API_URL } from '../config' - -export const MAPPINGS_REFRESH = 'MAPPINGS_REFRESH' -export const MAPPINGS_SAVE = 'MAPPINGS_SAVE' -export const MAPPINGS_DELETE_MAPPING = 'MAPPINGS_DELETE_MAPPING' -export const MAPPINGS_RENAME_MAPPING = 'MAPPINGS_RENAME_MAPPING' -export const MAPPINGS_SET_CURRENT = 'MAPPINGS_SET_CURRENT' -export const MAPPINGS_START_EDITING = 'MAPPINGS_START_EDITING' - -export function refresh () { - return { - type: MAPPINGS_REFRESH, - payload: { - promise: fetch(`${API_URL}/api/keyboard/mapping`).then(x => x.json()), - }, - } -} - -export function saveMapping (name, mapping) { - return { - type: MAPPINGS_SAVE, - payload: { - promise: fetch(`${API_URL}/api/keyboard/mapping`, { - method: 'POST', - body: JSON.stringify({ - name, - mapping, - }), - }), - }, - } -} - -export function setCurrent (name) { - return { - type: MAPPINGS_SET_CURRENT, - payload: { - promise: fetch(`${API_URL}/api/keyboard/mapping/current`, { - method: 'POST', - body: name, - }), - data: name, - }, - } -} - -export function startEditing (name) { - return { - type: MAPPINGS_START_EDITING, - payload: name, - } -} - -export function deleteMapping (name) { - return { - type: MAPPINGS_DELETE_MAPPING, - payload: { - promise: fetch(`${API_URL}/api/keyboard/mapping`, { - method: 'DELETE', - body: name, - }), - data: name, - }, - } -} - -export function renameMapping (name, newName) { - return { - type: MAPPINGS_RENAME_MAPPING, - payload: { - promise: fetch(`${API_URL}/api/keyboard/mapping/rename`, { - method: 'POST', - body: JSON.stringify({ - name, - newName, - }), - }), - data: { - name, - newName, - }, - }, - } -} \ No newline at end of file diff --git a/webapp/src/mappings/reducer.js b/webapp/src/mappings/reducer.js deleted file mode 100644 index e292ac6..0000000 --- a/webapp/src/mappings/reducer.js +++ /dev/null @@ -1,37 +0,0 @@ -import createReducer from '../redux/createReducer' - -import * as actions from './actions' - -const initialState = { - currentMapping: '', - mappingNames: [], - all: {}, - currentEditing: null, - editingStartedAt: null, -} - -export default createReducer (initialState, { - [`${actions.MAPPINGS_SET_CURRENT}_START`] (state, action) { - return { - ...state, - currentMapping: action.payload, - } - }, - - [`${actions.MAPPINGS_REFRESH}_SUCCESS`] (state, action) { - return { - ...state, - currentMapping: action.payload.currentMapping, - all: action.payload.mappings, - mappingNames: Object.keys(action.payload.mappings), - } - }, - - [actions.MAPPINGS_START_EDITING] (state, action) { - return { - ...state, - currentEditing: action.payload, - editingStartedAt: new Date(), - } - }, -}) diff --git a/webapp/src/redux/createReducer.js b/webapp/src/redux/createReducer.js deleted file mode 100644 index 6501327..0000000 --- a/webapp/src/redux/createReducer.js +++ /dev/null @@ -1,9 +0,0 @@ -export default function createReducer (initialState, handlers) { - return function reducer (state = initialState, action) { - if (handlers.hasOwnProperty(action.type)) { - return handlers[action.type](state, action) - } else { - return state - } - } -} diff --git a/webapp/src/redux/createStore.js b/webapp/src/redux/createStore.js deleted file mode 100644 index 12708bf..0000000 --- a/webapp/src/redux/createStore.js +++ /dev/null @@ -1,32 +0,0 @@ -import { - createStore as _createStore, - applyMiddleware, -} from 'redux' -import thunk from 'redux-thunk' -import promise from 'redux-promise-middleware' -import { createLogger } from 'redux-logger' - -const middleware = [ - thunk, - promise({ promiseTypeSuffixes: [ 'START', 'SUCCESS', 'ERROR' ] }), - createLogger(), -] - -const createStoreWithMiddleware = applyMiddleware(...middleware)(_createStore) - -function getRootReducer () { - // eslint-disable-next-line global-require - return require('./rootReducer').default -} - -export default function createStore (initialState) { - const store = createStoreWithMiddleware(getRootReducer(), initialState) - - if (module.hot) { - module.hot.accept('./rootReducer', () => { - store.replaceReducer(getRootReducer()) - }) - } - - return store -} diff --git a/webapp/src/redux/rootReducer.js b/webapp/src/redux/rootReducer.js deleted file mode 100644 index 278a158..0000000 --- a/webapp/src/redux/rootReducer.js +++ /dev/null @@ -1,11 +0,0 @@ -import { combineReducers } from 'redux' - -import status from '../status/reducer' -import mappings from '../mappings/reducer' - -const rootReducer = combineReducers({ - status, - mappings, -}) - -export default rootReducer diff --git a/webapp/src/status/actions.js b/webapp/src/status/actions.js deleted file mode 100644 index 87719e4..0000000 --- a/webapp/src/status/actions.js +++ /dev/null @@ -1,52 +0,0 @@ -import { API_URL } from '../config' - -export const STATUS_SET_KEYBOARDMAPPER = 'STATUS_SET_KEYBOARDMAPPER' -export const STATUS_SET_CONTROLLERMANAGER = 'STATUS_SET_CONTROLLERMANAGER' -export const STATUS_REFRESH = 'STATUS_REFRESH' - -export function setKeyboardmapper (shouldEnable) { - const endpoint = shouldEnable - ? `${API_URL}/api/keyboard/start` - : `${API_URL}/api/keyboard/stop` - - return { - type: STATUS_SET_KEYBOARDMAPPER, - payload: { - promise: fetch(endpoint, { method: 'POST' }), - data: shouldEnable, - }, - } -} - -export function setControllermanager (shouldEnable) { - const endpoint = shouldEnable - ? `${API_URL}/api/controller/start` - : `${API_URL}/api/controller/stop` - - return { - type: STATUS_SET_CONTROLLERMANAGER, - payload: { - promise: fetch(endpoint, { method: 'POST' }), - data: shouldEnable, - }, - } -} - -export function setAll (shouldEnable) { return (dispatch, getState) => { - return dispatch(setKeyboardmapper(shouldEnable)) - .then(() => dispatch(setControllermanager(shouldEnable))) -} } - -export function restartAll () { return (dispatch, getState) => { - return dispatch(setAll(false)) - .then(() => dispatch(setAll(true))) -} } - -export function refresh () { - return { - type: STATUS_REFRESH, - payload: { - promise: fetch(`${API_URL}/api/status`).then(x => x.json()), - }, - } -} diff --git a/webapp/src/status/reducer.js b/webapp/src/status/reducer.js deleted file mode 100644 index 093de9b..0000000 --- a/webapp/src/status/reducer.js +++ /dev/null @@ -1,32 +0,0 @@ -import createReducer from '../redux/createReducer' - -import * as actions from './actions' - -const initialState = { - isKeyboardRunning: false, - isControllerRunning: false, - hostname: null, -} - -export default createReducer (initialState, { - [`${actions.STATUS_SET_KEYBOARDMAPPER}_START`] (state, action) { - return { - ...state, - isKeyboardRunning: action.payload, - } - }, - - [`${actions.STATUS_SET_CONTROLLERMANAGER}_START`] (state, action) { - return { - ...state, - isControllerRunning: action.payload, - } - }, - - [`${actions.STATUS_REFRESH}_SUCCESS`] (state, action) { - return { - ...state, - ...action.payload, - } - }, -}) From 56f7a5a712ebaffc21bced3b0e010d1be5a62cf8 Mon Sep 17 00:00:00 2001 From: Mike Wyatt Date: Sat, 14 Jul 2018 21:52:08 -0300 Subject: [PATCH 02/16] switch to typescript --- webapp/src/App.test.tsx | 7 + webapp/src/App.tsx | 13 ++ webapp/src/commonRedux.ts | 108 ++++++++++++++ webapp/src/index.tsx | 33 +++++ webapp/src/mappings/MappingEditor.tsx | 199 ++++++++++++++++++++++++++ webapp/src/mappings/MappingEntry.tsx | 112 +++++++++++++++ webapp/src/mappings/MappingList.tsx | 44 ++++++ webapp/src/mappings/actions.ts | 51 +++++++ webapp/src/mappings/reducer.ts | 55 +++++++ webapp/src/mappings/selectors.ts | 14 ++ webapp/src/redux/createStore.ts | 36 +++++ webapp/src/redux/rootReducer.ts | 16 +++ webapp/src/registerServiceWorker.ts | 118 +++++++++++++++ webapp/src/setupTests.ts | 34 +++++ webapp/src/status/Status.tsx | 80 +++++++++++ webapp/src/status/actions.ts | 67 +++++++++ webapp/src/status/reducer.ts | 48 +++++++ webapp/src/status/selectors.ts | 5 + webapp/src/test/mountComponent.tsx | 31 ++++ webapp/src/util/IconMenu.tsx | 74 ++++++++++ webapp/tsconfig.json | 31 ++++ webapp/tsconfig.prod.json | 3 + webapp/tsconfig.test.json | 6 + webapp/tslint.json | 13 ++ 24 files changed, 1198 insertions(+) create mode 100644 webapp/src/App.test.tsx create mode 100644 webapp/src/App.tsx create mode 100644 webapp/src/commonRedux.ts create mode 100644 webapp/src/index.tsx create mode 100644 webapp/src/mappings/MappingEditor.tsx create mode 100644 webapp/src/mappings/MappingEntry.tsx create mode 100644 webapp/src/mappings/MappingList.tsx create mode 100644 webapp/src/mappings/actions.ts create mode 100644 webapp/src/mappings/reducer.ts create mode 100644 webapp/src/mappings/selectors.ts create mode 100644 webapp/src/redux/createStore.ts create mode 100644 webapp/src/redux/rootReducer.ts create mode 100644 webapp/src/registerServiceWorker.ts create mode 100644 webapp/src/setupTests.ts create mode 100644 webapp/src/status/Status.tsx create mode 100644 webapp/src/status/actions.ts create mode 100644 webapp/src/status/reducer.ts create mode 100644 webapp/src/status/selectors.ts create mode 100644 webapp/src/test/mountComponent.tsx create mode 100644 webapp/src/util/IconMenu.tsx create mode 100644 webapp/tsconfig.json create mode 100644 webapp/tsconfig.prod.json create mode 100644 webapp/tsconfig.test.json create mode 100644 webapp/tslint.json diff --git a/webapp/src/App.test.tsx b/webapp/src/App.test.tsx new file mode 100644 index 0000000..670e87a --- /dev/null +++ b/webapp/src/App.test.tsx @@ -0,0 +1,7 @@ +import * as React from 'react' +import App from './App' +import mountComponent from './test/mountComponent' + +it('renders without crashing', () => { + mountComponent() +}) diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx new file mode 100644 index 0000000..c372af6 --- /dev/null +++ b/webapp/src/App.tsx @@ -0,0 +1,13 @@ +import * as React from 'react' + +import MappingList from './mappings/MappingList' +import Status from './status/Status' + +export default class App extends React.Component { + render() { + return
+ + +
+ } +} diff --git a/webapp/src/commonRedux.ts b/webapp/src/commonRedux.ts new file mode 100644 index 0000000..bbc0f34 --- /dev/null +++ b/webapp/src/commonRedux.ts @@ -0,0 +1,108 @@ +// The intent of this file is to collect functions and interfaces used when +// dealing with redux. +// +// It also contains a handful of "app-specific" interfaces, which are the redux +// ones with the generic StoreShape and Action types set to the ones used in +// our app. + +import { + connect as originalConnect, + InferableComponentEnhancerWithProps, + MapStateToProps, + MapStateToPropsParam, +} from 'react-redux' +import { + Action, + Dispatch, +} from 'redux' +/** + * Base Flux action with generic types for payload and meta. + * @export + * @interface FluxAction + * @extends {Action} + * @template TPayload + * @template TMeta + */ +export interface FluxAction extends Action { + payload: TPayload + meta?: TMeta + error?: boolean +} + +export { RootStore } from './redux/rootReducer' +import { RootStore } from './redux/rootReducer' + +/** + * App-specific interface for dispatching actions. + * @export + * @interface AppDispatch + * @extends {Dispatch} + */ +export interface AppDispatch extends Dispatch {} + +/** + * App-specific interface to be extended by component props. + * @export + * @interface AppDispatchProps + */ +export interface AppDispatchProps { + dispatch: AppDispatch +} + +export interface AppGetState { + (): RootStore +} + +/** + * App-specific interface for `action.payload` functions. + * @export + * @interface AppPayloadFunction + */ +export interface AppPayloadFunction { + (dispatch: AppDispatch, getState: AppGetState): T +} + +// export interface AppAction { +// (...args: any[]): FluxAction +// } + +/** + * App-specific interface for actions with a payload function. + * @export + * @interface AppAsyncAction + */ +// tslint:disable-next-line:max-line-length +export interface AppAsyncAction extends FluxAction, TMeta> { +} + +/** + * App-specific interface for actions with a payload function and meta. + * @export + * @interface AppAsyncMetaAction + * @template TMeta + */ +// tslint:disable-next-line:max-line-length +// export interface AppAsyncMetaAction extends FluxAction, TMeta> { +// } + +/** + * App-specific interface for `mapStateToProps`. + * @export + * @interface AppConnector + * @template TStateProps + * @template TOwnProps + */ +export interface AppConnector extends MapStateToProps< + TStateProps, + TOwnProps, + RootStore +> {} + +interface AppConnect { + ( + mapStateToProps?: MapStateToPropsParam, + mapDispatchToProps?: TDispatchProps | ((...args: any[]) => TDispatchProps), + ): InferableComponentEnhancerWithProps +} + +export const connect = originalConnect as AppConnect diff --git a/webapp/src/index.tsx b/webapp/src/index.tsx new file mode 100644 index 0000000..3874e3a --- /dev/null +++ b/webapp/src/index.tsx @@ -0,0 +1,33 @@ +import 'url-search-params-polyfill' + +import * as React from 'react' +import * as ReactDOM from 'react-dom' +import { Provider } from 'react-redux' + +import CssBaseline from '@material-ui/core/CssBaseline/CssBaseline' + +import createStore from './redux/createStore' + +import './index.css' + +function renderApp() { + // tslint:disable-next-line:variable-name + const App = require('./App').default + + ReactDOM.render( + + + + + , + document.getElementById('root'), + ) +} + +export const store = createStore() + +renderApp() + +if (module.hot) { + module.hot.accept('./App', renderApp) +} diff --git a/webapp/src/mappings/MappingEditor.tsx b/webapp/src/mappings/MappingEditor.tsx new file mode 100644 index 0000000..88004b2 --- /dev/null +++ b/webapp/src/mappings/MappingEditor.tsx @@ -0,0 +1,199 @@ +// tslint:disable-next-line:import-name +import AceEditor, { AceEditorProps } from 'react-ace' + +import 'brace/mode/json' +import 'brace/theme/tomorrow_night_eighties' + +import Button from '@material-ui/core/Button/Button' +import Dialog from '@material-ui/core/Dialog/Dialog' +import DialogActions from '@material-ui/core/DialogActions/DialogActions' +import DialogContent from '@material-ui/core/DialogContent/DialogContent' +import DialogContentText from '@material-ui/core/DialogContentText/DialogContentText' +import DialogTitle from '@material-ui/core/DialogTitle/DialogTitle' +import Icon from '@material-ui/core/Icon/Icon' +import TextField, { TextFieldProps } from '@material-ui/core/TextField/TextField' +import * as React from 'react' + +import { + AppDispatchProps, + connect, +} from '../commonRedux' + +import * as actions from './actions' +import * as selectors from './selectors' + +interface ReduxProps { + editingStartedAt: ReturnType + currentEditing: ReturnType + mapping: ReturnType +} + +interface State { + isOpen: boolean + isRenameDialogOpen: boolean +} + +class MappingEditor extends React.PureComponent { + aceEditor = React.createRef() + renameInput = React.createRef() + + renameInputProps: TextFieldProps['inputProps'] = { + ref: this.renameInput, + } + + aceEditorProps: AceEditorProps['editorProps'] = { + $blockScrolling: true, + } + + aceEditorSetOptions: AceEditorProps['setOptions'] = { + fontFamily: 'SFMono-Regular, Consolas, "Liberation Mono", Menlo, Courier, monospace', + } + + state: State = { + isOpen: false, + isRenameDialogOpen: false, + } + + componentDidUpdate (prevProps: ReduxProps) { + if (prevProps.editingStartedAt !== this.props.editingStartedAt) { + this.setState({ isOpen: true }) + } + } + + render () { + return { + // HACK Reset overflow / padding because material-ui ain't + setTimeout(() => { + document.body.style.overflow = '' + document.body.style.paddingRight = '' + }, 300) + }} + > + + Edit "{this.props.currentEditing}" + + + {this.renderRenameDialog()} + + + + + + + + + + + } + + renderRenameDialog () { + const defaultName = `${this.props.currentEditing} - ${Math.random().toString(16).substr(2)}` + + return + + Enter a new name + + + + + + + + + + } + + handleClose = () => { + this.setState({ isOpen: false }) + } + + handleRenameDialogClose = () => { + this.setState({ isRenameDialogOpen: false }) + } + + openRenameDialog = () => { + this.setState({ isRenameDialogOpen: true }) + } + + save = () => { + this._save() + } + + saveWithRename = () => { + if (!this.renameInput.current) { + return + } + + this._save(this.renameInput.current.value) + } + + _save = async (name?: string, mapping?: string) => { + if (!name) { + // tslint:disable-next-line:no-parameter-reassignment + name = this.props.currentEditing || undefined + } + + if (!mapping) { + // tslint:disable-next-line:no-parameter-reassignment + mapping = this.aceEditor.current + ? (this.aceEditor.current as any).editor.getValue() + : undefined + } + + if (!name || !mapping) { + return + } + + await this.props.dispatch(actions.saveMapping(name, mapping)) + await this.props.dispatch(actions.refresh()) + this.handleRenameDialogClose() + this.handleClose() + } +} + +export default connect((state) => ({ + editingStartedAt: selectors.editingStartedAt(state), + currentEditing: selectors.currentEditing(state), + mapping: selectors.currentEditingMapping(state), +}))(MappingEditor) diff --git a/webapp/src/mappings/MappingEntry.tsx b/webapp/src/mappings/MappingEntry.tsx new file mode 100644 index 0000000..4475fab --- /dev/null +++ b/webapp/src/mappings/MappingEntry.tsx @@ -0,0 +1,112 @@ +import Icon from '@material-ui/core/Icon/Icon' +import ListItem from '@material-ui/core/ListItem/ListItem' +import ListItemIcon from '@material-ui/core/ListItemIcon/ListItemIcon' +// tslint:disable-next-line:max-line-length +import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction/ListItemSecondaryAction' +import ListItemText from '@material-ui/core/ListItemText/ListItemText' +import MenuItem from '@material-ui/core/MenuItem/MenuItem' +import * as React from 'react' + +import { + AppDispatchProps, + connect, +} from '../commonRedux' + +import IconMenu from '../util/IconMenu' + +import * as actions from './actions' + +interface Props { + isActive: boolean + name: string +} + +class MappingEntry extends React.PureComponent { + menu = React.createRef() + + render () { + let icon = null + + if (this.props.isActive) { + icon = + check_circle + + } + + return + {icon} + + + + + + check_circle + + Make Active + + + + + edit + + Edit + + + + + label + + Rename + + + + + delete_forever + + Delete + + + + + } + + makeActive = async () => { + this.menu.current!.closeMenuWithDelay() + + await this.props.dispatch(actions.setCurrent(this.props.name)) + + return this.props.dispatch(actions.refresh()) + } + + startEditing = () => { + this.menu.current!.closeMenuWithDelay() + + this.props.dispatch(actions.startEditing(this.props.name)) + } + + requestDelete = async () => { + if (!confirm(`Really delete ${this.props.name}? This cannot be undone.`)) { + return + } + + this.menu.current!.closeMenuWithDelay() + await this.props.dispatch(actions.deleteMapping(this.props.name)) + + return this.props.dispatch(actions.refresh()) + } + + startRename = async () => { + const newName = prompt(`New name for ${this.props.name}`, this.props.name) + + if (newName == null) { + return + } + + this.menu.current!.closeMenuWithDelay() + await this.props.dispatch(actions.renameMapping(this.props.name, newName)) + + return this.props.dispatch(actions.refresh()) + } +} + +export default connect<{}, {}, AppDispatchProps>()(MappingEntry) diff --git a/webapp/src/mappings/MappingList.tsx b/webapp/src/mappings/MappingList.tsx new file mode 100644 index 0000000..88c42b2 --- /dev/null +++ b/webapp/src/mappings/MappingList.tsx @@ -0,0 +1,44 @@ +import List from '@material-ui/core/List/List' +import ListSubheader from '@material-ui/core/ListSubheader/ListSubheader' +import * as React from 'react' + +import { + AppDispatchProps, + connect, +} from '../commonRedux' + +import MappingEditor from './MappingEditor' +import MappingEntry from './MappingEntry' + +import * as actions from './actions' +import * as selectors from './selectors' + +interface ReduxProps { + mappingNames: ReturnType + currentMapping: ReturnType +} + +class MappingList extends React.PureComponent { + componentDidMount () { + this.props.dispatch(actions.refresh()) + } + + render () { + const children = this.props.mappingNames.map((x) => { + const isActive = x === this.props.currentMapping + + return + }) + + return + + Mappings + {children} + + } +} + +export default connect((state) => ({ + mappingNames: selectors.mappingNames(state), + currentMapping: selectors.currentMapping(state), +}))(MappingList) diff --git a/webapp/src/mappings/actions.ts b/webapp/src/mappings/actions.ts new file mode 100644 index 0000000..be98719 --- /dev/null +++ b/webapp/src/mappings/actions.ts @@ -0,0 +1,51 @@ +import { applyNamespace, createAction } from 'redux-ts-helpers' + +import api from '../api' + +export const constants = applyNamespace('mappings', { + refresh: 0, + saveMapping: 0, + deleteMapping: 0, + renameMapping: 0, + setCurrent: 0, + startEditing: 0, +}) + +export const refresh = () => ({ + type: constants.refresh, + payload: api.keyboard.keyboardMappingGet(), +}) + +export const saveMapping = (name: string, mapping: string) => ({ + type: constants.saveMapping, + payload: api.keyboard.keyboardMappingPost({ + name, + mapping, + }), +}) + +export const setCurrent = (name: string) => ({ + type: constants.setCurrent, + payload: api.keyboard.keyboardMappingCurrentPost(name), + meta: name, +}) + +export const startEditing = createAction(constants.startEditing) + +export const deleteMapping = (name: string) => ({ + type: constants.deleteMapping, + payload: api.keyboard.keyboardMappingDelete(name), + meta: name, +}) + +export const renameMapping = (name: string, newName: string) => ({ + type: constants.renameMapping, + payload: api.keyboard.keyboardMappingRenamePost({ + name, + newName, + }), + meta: { + name, + newName, + }, +}) diff --git a/webapp/src/mappings/reducer.ts b/webapp/src/mappings/reducer.ts new file mode 100644 index 0000000..0691688 --- /dev/null +++ b/webapp/src/mappings/reducer.ts @@ -0,0 +1,55 @@ +import { PayloadType } from 'redux-async-payload' +import { createReducer } from 'redux-ts-helpers' + +import * as actions from './actions' + +export interface State { + currentMapping: string + mappingNames: string[] + all: {[mappingName: string]: string} + currentEditing: string | null + editingStartedAt: Date | null +} + +const initialState: State = { + currentMapping: '', + mappingNames: [], + all: {}, + currentEditing: null, + editingStartedAt: null, +} + +export const reducer = createReducer(initialState, { + [`${actions.constants.setCurrent}/start`]: ( + state, + action: ReturnType, + ) => { + return { + ...state, + currentMapping: action.meta, + } + }, + + [`${actions.constants.refresh}/success`]: ( + state, + action: ReturnType, + ) => { + const payload = action.payload as PayloadType + + return { + ...state, + currentMapping: payload.currentMapping!, + all: payload.mappings!, + mappingNames: Object.keys(payload.mappings!), + } + }, + + [actions.constants.startEditing]: ( + state, + action: ReturnType, + ) => ({ + ...state, + currentEditing: action.payload, + editingStartedAt: new Date(), + }), +}) diff --git a/webapp/src/mappings/selectors.ts b/webapp/src/mappings/selectors.ts new file mode 100644 index 0000000..e62b3d0 --- /dev/null +++ b/webapp/src/mappings/selectors.ts @@ -0,0 +1,14 @@ +import { createSelector } from 'reselect' + +import { RootStore } from '../redux/rootReducer' + +export const mappingNames = (state: RootStore) => state.mappings.mappingNames +export const currentMapping = (state: RootStore) => state.mappings.currentMapping + +export const editingStartedAt = (state: RootStore) => state.mappings.editingStartedAt +export const currentEditing = (state: RootStore) => state.mappings.currentEditing +export const all = (state: RootStore) => state.mappings.all +export const currentEditingMapping = createSelector( + [currentEditing, all], + (_currentEditing, _all) => _currentEditing && _all[_currentEditing], +) diff --git a/webapp/src/redux/createStore.ts b/webapp/src/redux/createStore.ts new file mode 100644 index 0000000..5f20b50 --- /dev/null +++ b/webapp/src/redux/createStore.ts @@ -0,0 +1,36 @@ +import { + applyMiddleware, + createStore as _createStore, + DeepPartial, + Store, +} from 'redux' +import reduxAsyncPayload from 'redux-async-payload' + +import { RootStore } from './rootReducer' + +const middleware = [ + reduxAsyncPayload(), +] + +function getRootReducer() { + // Importing this strange way is needed for hot loading. + return require('./rootReducer').default +} + +export default function createStore(initialState?: DeepPartial) { + // Type errors came in after upgrading to redux@4 + typescript@2.8. + // Now a cast to Store is needed. + const store = _createStore( + getRootReducer(), + initialState || {}, + applyMiddleware(...middleware), + ) as Store + + if (module.hot) { + module.hot.accept('./rootReducer', () => { + store.replaceReducer(getRootReducer()) + }) + } + + return store +} diff --git a/webapp/src/redux/rootReducer.ts b/webapp/src/redux/rootReducer.ts new file mode 100644 index 0000000..11ef9c6 --- /dev/null +++ b/webapp/src/redux/rootReducer.ts @@ -0,0 +1,16 @@ +import { combineReducers } from 'redux' + +import * as mappings from '../mappings/reducer' +import * as status from '../status/reducer' + +export interface RootStore { + mappings: mappings.State + status: status.State, +} + +const rootReducer = combineReducers({ + mappings: mappings.reducer, + status: status.reducer, +}) + +export default rootReducer diff --git a/webapp/src/registerServiceWorker.ts b/webapp/src/registerServiceWorker.ts new file mode 100644 index 0000000..33cd0b3 --- /dev/null +++ b/webapp/src/registerServiceWorker.ts @@ -0,0 +1,118 @@ +// tslint:disable:no-console +// tslint:disable:semicolon +// tslint:disable:trailing-comma +// tslint:disable:ter-arrow-parens +// tslint:disable:arrow-parens +// In production, we register a service worker to serve assets from local cache. + +// This lets the app load faster on subsequent visits in production, and gives +// it offline capabilities. However, it also means that developers (and users) +// will only see deployed updates on the 'N+1' visit to a page, since previously +// cached resources are updated in the background. + +// To learn more about the benefits of this model, read https://goo.gl/KwvDNy. +// This link also includes instructions on opting out of this behavior. + +const isLocalhost = Boolean( + window.location.hostname === 'localhost' || + // [::1] is the IPv6 localhost address. + window.location.hostname === '[::1]' || + // 127.0.0.1/8 is considered localhost for IPv4. + window.location.hostname.match( + /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ + ) +); + +export default function register() { + if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { + // The URL constructor is available in all browsers that support SW. + const publicUrl = new URL( + process.env.PUBLIC_URL!, + window.location.toString() + ); + if (publicUrl.origin !== window.location.origin) { + // Our service worker won't work if PUBLIC_URL is on a different origin + // from what our page is served on. This might happen if a CDN is used to + // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374 + return; + } + + window.addEventListener('load', () => { + const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; + + if (!isLocalhost) { + // Is not local host. Just register service worker + registerValidSW(swUrl); + } else { + // This is running on localhost. Lets check if a service worker still exists or not. + checkValidServiceWorker(swUrl); + } + }); + } +} + +function registerValidSW(swUrl: string) { + navigator.serviceWorker + .register(swUrl) + .then(registration => { + registration.onupdatefound = () => { + const installingWorker = registration.installing; + if (installingWorker) { + installingWorker.onstatechange = () => { + if (installingWorker.state === 'installed') { + if (navigator.serviceWorker.controller) { + // At this point, the old content will have been purged and + // the fresh content will have been added to the cache. + // It's the perfect time to display a 'New content is + // available; please refresh.' message in your web app. + console.log('New content is available; please refresh.'); + } else { + // At this point, everything has been precached. + // It's the perfect time to display a + // 'Content is cached for offline use.' message. + console.log('Content is cached for offline use.'); + } + } + }; + } + }; + }) + .catch(error => { + console.error('Error during service worker registration:', error); + }); +} + +function checkValidServiceWorker(swUrl: string) { + // Check if the service worker can be found. If it can't reload the page. + fetch(swUrl) + .then(response => { + // Ensure service worker exists, and that we really are getting a JS file. + if ( + response.status === 404 || + response.headers.get('content-type')!.indexOf('javascript') === -1 + ) { + // No service worker found. Probably a different app. Reload the page. + navigator.serviceWorker.ready.then(registration => { + registration.unregister().then(() => { + window.location.reload(); + }); + }); + } else { + // Service worker found. Proceed as normal. + registerValidSW(swUrl); + } + }) + .catch(() => { + console.log( + 'No internet connection found. App is running in offline mode.' + ); + }); +} + +export function unregister() { + if ('serviceWorker' in navigator) { + navigator.serviceWorker.ready.then(registration => { + registration.unregister(); + }); + } +} diff --git a/webapp/src/setupTests.ts b/webapp/src/setupTests.ts new file mode 100644 index 0000000..f223634 --- /dev/null +++ b/webapp/src/setupTests.ts @@ -0,0 +1,34 @@ +// This shim needs to be first to avoid React complaining about rAF: +// https://github.com/facebook/jest/issues/4545#issuecomment-342424086 +(global as any).requestAnimationFrame = (callback) => { + setTimeout(callback, 0) +} +;(global as any).localStorage = { + // readonly length: number; + clear: jest.fn(), + getItem: jest.fn(), + key: jest.fn(), + removeItem: jest.fn(), + setItem: jest.fn(), + // [key: string]: any; + // [index: number]: string; +} + +// Needed for some components that use material-ui +// tslint:disable-next-line:max-line-length +// https://github.com/mui-org/material-ui/blob/c0afa64dbc7e331cbf13ab30ee58ffb71958c40b/test/utils/createDOM.js#L21-L28 +;(global as any).document.createRange = () => ({ + setStart: () => {}, + setEnd: () => {}, + commonAncestorContainer: { + nodeName: 'BODY', + ownerDocument: document, + }, +}) + +import * as Enzyme from 'enzyme' +import * as Adapter from 'enzyme-adapter-react-16' + +Enzyme.configure({ + adapter: new Adapter(), +}) diff --git a/webapp/src/status/Status.tsx b/webapp/src/status/Status.tsx new file mode 100644 index 0000000..4c281b7 --- /dev/null +++ b/webapp/src/status/Status.tsx @@ -0,0 +1,80 @@ +import Icon from '@material-ui/core/Icon/Icon' +import IconButton from '@material-ui/core/IconButton/IconButton' +import List from '@material-ui/core/List/List' +import ListItem from '@material-ui/core/ListItem/ListItem' +// tslint:disable-next-line:max-line-length +import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction/ListItemSecondaryAction' +import ListItemText from '@material-ui/core/ListItemText/ListItemText' +import ListSubheader from '@material-ui/core/ListSubheader/ListSubheader' +import Switch from '@material-ui/core/Switch/Switch' +import * as React from 'react' + +import { + AppDispatchProps, connect, +} from '../commonRedux' + +import * as actions from './actions' +import * as selectors from './selectors' + +interface ReduxProps { + isKeyboardRunning: ReturnType + isControllerRunning: ReturnType + hostname: ReturnType +} + +class Status extends React.PureComponent { + componentDidMount () { + this.props.dispatch(actions.refresh()) + } + + render () { + const isRunning = this.props.isKeyboardRunning && this.props.isControllerRunning + let heading = isRunning + ? `Running` + : 'Not Running' + + if (this.props.hostname) { + heading = `${heading} on ${this.props.hostname}` + } + + return + + Status: {heading} + + + + + + + refresh + + + + + } + + handleEnabledChange = (event: any, checked: boolean) => { + this.props.dispatch(actions.setAll(checked)) + } + + handleRestartClick = () => { + this.props.dispatch(actions.restartAll()) + } +} + +export default connect((state) => ({ + isKeyboardRunning: selectors.isKeyboardRunning(state), + isControllerRunning: selectors.isControllerRunning(state), + hostname: selectors.hostname(state), +}))(Status) diff --git a/webapp/src/status/actions.ts b/webapp/src/status/actions.ts new file mode 100644 index 0000000..4944e66 --- /dev/null +++ b/webapp/src/status/actions.ts @@ -0,0 +1,67 @@ +import { applyNamespace } from 'redux-ts-helpers' + +import api from '../api' +import { AppAsyncAction } from '../commonRedux' + +export const constants = applyNamespace('status', { + setKeyboardMapper: 0, + setControllerManager: 0, + refresh: 0, + setAll: 0, + restartAll: 0, +}) + +export const setKeyboardMapper = (shouldEnable: boolean) => { + const fn = shouldEnable + ? api.keyboard.keyboardStartPost + : api.keyboard.keyboardStopPost + + return { + type: constants.setKeyboardMapper, + payload: fn(), + meta: shouldEnable, + } +} + +export const setControllerManager = (shouldEnable: boolean) => { + const fn = shouldEnable + ? api.controller.controllerStartPost + : api.controller.controllerStopPost + + return { + type: constants.setControllerManager, + payload: fn(), + meta: shouldEnable, + } +} + +export const setAll = (shouldEnable: boolean): AppAsyncAction => ({ + type: constants.setAll, + async payload(dispatch) { + await dispatch(setKeyboardMapper(shouldEnable)) + await dispatch(setControllerManager(shouldEnable)) + }, + meta: { + asyncPayload: { + skipOuter: true, + }, + }, +}) + +export const restartAll = (): AppAsyncAction => ({ + type: constants.restartAll, + async payload(dispatch) { + await dispatch(setAll(false)) + await dispatch(setAll(true)) + }, + meta: { + asyncPayload: { + skipOuter: true, + }, + }, +}) + +export const refresh = () => ({ + type: constants.refresh, + payload: api.default.statusGet(), +}) diff --git a/webapp/src/status/reducer.ts b/webapp/src/status/reducer.ts new file mode 100644 index 0000000..f1c99ac --- /dev/null +++ b/webapp/src/status/reducer.ts @@ -0,0 +1,48 @@ +import { PayloadType } from 'redux-async-payload' +import { createReducer } from 'redux-ts-helpers' + +import * as actions from './actions' + +export interface State { + isKeyboardRunning: boolean + isControllerRunning: boolean + hostname: string | null +} + +const initialState: State = { + isKeyboardRunning: false, + isControllerRunning: false, + hostname: null, +} + +export const reducer = createReducer(initialState, { + [`${actions.constants.setKeyboardMapper}/start`]: ( + state, + action: ReturnType, + ) => { + return { + ...state, + isKeyboardRunning: action.meta, + } + }, + + [`${actions.constants.setControllerManager}/start`]: ( + state, + action: ReturnType, + ) => ({ + ...state, + isControllerRunning: action.meta, + }), + + [`${actions.constants.refresh}/success`]: ( + state, + action: ReturnType, + ) => { + const payload = action.payload as PayloadType + + return { + ...state, + ...payload, + } + }, +}) diff --git a/webapp/src/status/selectors.ts b/webapp/src/status/selectors.ts new file mode 100644 index 0000000..daa459e --- /dev/null +++ b/webapp/src/status/selectors.ts @@ -0,0 +1,5 @@ +import { RootStore } from '../redux/rootReducer' + +export const isKeyboardRunning = (state: RootStore) => state.status.isKeyboardRunning +export const isControllerRunning = (state: RootStore) => state.status.isControllerRunning +export const hostname = (state: RootStore) => state.status.hostname diff --git a/webapp/src/test/mountComponent.tsx b/webapp/src/test/mountComponent.tsx new file mode 100644 index 0000000..e018428 --- /dev/null +++ b/webapp/src/test/mountComponent.tsx @@ -0,0 +1,31 @@ +import { mount, MountRendererProps } from 'enzyme' +import * as React from 'react' +import { Provider } from 'react-redux' +import { Store } from 'redux' + +import createStore from '../redux/createStore' +import { RootStore } from '../redux/rootReducer' + +export interface MountComponentOptions extends MountRendererProps { + store?: Store +} + +export default function mountComponent

( + element: React.ReactElement

, + options: MountComponentOptions = {}, +) { + const store = options.store || createStore() + + const wrapper = mount( + + {element} + , + options, + ) + + return { + element, + store, + wrapper, + } +} diff --git a/webapp/src/util/IconMenu.tsx b/webapp/src/util/IconMenu.tsx new file mode 100644 index 0000000..e4217f5 --- /dev/null +++ b/webapp/src/util/IconMenu.tsx @@ -0,0 +1,74 @@ +import Icon from '@material-ui/core/Icon/Icon' +import IconButton from '@material-ui/core/IconButton/IconButton' +import Menu from '@material-ui/core/Menu/Menu' +import * as React from 'react' + +interface Props { + icon: string +} + +interface State { + menuAnchor: HTMLElement | null + isMenuOpen: boolean +} + +export default class IconMenu extends React.PureComponent { + state: State = { + menuAnchor: null, + isMenuOpen: false, + } + + _closeTimeout: NodeJS.Timer | null + + componentWillUnmount () { + if (this._closeTimeout) { + clearTimeout(this._closeTimeout) + } + } + + render () { + const { + icon, + children, + // tslint:disable-next-line:trailing-comma + ...props + } = this.props + + return

+ + {icon} + + + {children} + +
+ } + + openMenu = (event: React.MouseEvent) => { + this.setState({ + menuAnchor: event.currentTarget, + isMenuOpen: true, + }) + } + + closeMenu = () => { + this._closeTimeout = null + this.setState({ + menuAnchor: null, + isMenuOpen: false, + }) + } + + closeMenuWithDelay = () => { + if (this._closeTimeout) { + clearTimeout(this._closeTimeout) + } + + this._closeTimeout = setTimeout(this.closeMenu, 200) + } +} diff --git a/webapp/tsconfig.json b/webapp/tsconfig.json new file mode 100644 index 0000000..266004d --- /dev/null +++ b/webapp/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "baseUrl": "src", + "outDir": "build/dist", + "module": "esnext", + "target": "es5", + "lib": ["es6", "dom"], + "sourceMap": true, + "allowJs": true, + "jsx": "react", + "moduleResolution": "node", + "rootDir": "src", + "forceConsistentCasingInFileNames": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noImplicitAny": true, + "strictNullChecks": true, + "suppressImplicitAnyIndexErrors": true, + "noUnusedLocals": true, + "experimentalDecorators": true + }, + "exclude": [ + "node_modules", + "build", + "scripts", + "acceptance-tests", + "webpack", + "jest", + "src/setupTests.ts" + ] +} diff --git a/webapp/tsconfig.prod.json b/webapp/tsconfig.prod.json new file mode 100644 index 0000000..fc8520e --- /dev/null +++ b/webapp/tsconfig.prod.json @@ -0,0 +1,3 @@ +{ + "extends": "./tsconfig.json" +} diff --git a/webapp/tsconfig.test.json b/webapp/tsconfig.test.json new file mode 100644 index 0000000..65ffdd4 --- /dev/null +++ b/webapp/tsconfig.test.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "commonjs" + } +} \ No newline at end of file diff --git a/webapp/tslint.json b/webapp/tslint.json new file mode 100644 index 0000000..cb5f809 --- /dev/null +++ b/webapp/tslint.json @@ -0,0 +1,13 @@ +{ + "defaultSeverity": "warning", + "extends": [ + "tslint-config-sst" + ], + "jsRules": {}, + "rulesDirectory": [], + "rules": { + "no-var-requires": false, + "align": false, + "strict-boolean-expressions": false + } +} \ No newline at end of file From 83b4d164775dc93c2a10afdd4ae8d5eee8cb4df1 Mon Sep 17 00:00:00 2001 From: Mike Wyatt Date: Sat, 14 Jul 2018 21:52:26 -0300 Subject: [PATCH 03/16] fix cors headers in RestServer --- XArcade XInput/RestServer.cs | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/XArcade XInput/RestServer.cs b/XArcade XInput/RestServer.cs index e6e2a51..692a766 100644 --- a/XArcade XInput/RestServer.cs +++ b/XArcade XInput/RestServer.cs @@ -42,7 +42,8 @@ public void Stop () { } static public void SetCORSHeaders (IHttpContext ctx) { - ctx.Response.Headers["Access-Control-Allow-Origin"] = "*"; + ctx.Response.AddHeader("Access-Control-Allow-Origin", "*"); + ctx.Response.AddHeader("Access-Control-Allow-Headers", "Content-Type, Accept, X-Requested-With"); } static public void SendTextResponse (IHttpContext ctx, string response) { @@ -67,6 +68,21 @@ static public Dictionary ParseJson (string json) { [RestResource] class DefaultRestResource { + [RestRoute(HttpMethod = HttpMethod.OPTIONS, PathInfo = @"^.*?$")] + public IHttpContext CorsOptions(IHttpContext ctx) { + var validMethods = new HttpMethod[] { + HttpMethod.GET, + HttpMethod.POST, + HttpMethod.DELETE, + HttpMethod.OPTIONS, + }; + ctx.Response.Headers["Access-Control-Allow-Methods"] = string.Join(", ", validMethods.Select(x => x.ToString())); + + RestServer.SetCORSHeaders(ctx); + RestServer.CloseResponse(ctx); + return ctx; + } + [RestRoute(HttpMethod = HttpMethod.GET, PathInfo = "/")] public IHttpContext Index (IHttpContext ctx) { string prefix = ctx.Server.PublicFolder.Prefix; @@ -159,19 +175,6 @@ public IHttpContext KeyboardSetMapping (IHttpContext ctx) { return ctx; } - [RestRoute(HttpMethod = HttpMethod.OPTIONS, PathInfo = "/api/keyboard/mapping")] - public IHttpContext KeyboardMappingOptions (IHttpContext ctx) { - var validMethods = new HttpMethod[] { - HttpMethod.GET, - HttpMethod.POST, - HttpMethod.DELETE, - HttpMethod.OPTIONS, - }; - ctx.Response.Headers["Access-Control-Allow-Methods"] = string.Join(", ", validMethods.Select(x => x.ToString())); - RestServer.SetCORSHeaders(ctx); - RestServer.CloseResponse(ctx); - return ctx; - } [RestRoute(HttpMethod = HttpMethod.DELETE, PathInfo = "/api/keyboard/mapping")] public IHttpContext KeyboardDeleteMapping (IHttpContext ctx) { From 2287493391f173d8ac02ec0899c180561ea09acd Mon Sep 17 00:00:00 2001 From: Mike Wyatt Date: Sat, 14 Jul 2018 21:54:04 -0300 Subject: [PATCH 04/16] swagger api --- swagger.yml | 186 +++++ webapp/src/api/generated/api.ts | 932 ++++++++++++++++++++++ webapp/src/api/generated/configuration.ts | 66 ++ webapp/src/api/generated/custom.d.ts | 1 + webapp/src/api/generated/index.ts | 16 + webapp/src/api/generated/mappings.ts | 0 webapp/src/api/index.ts | 10 + 7 files changed, 1211 insertions(+) create mode 100644 swagger.yml create mode 100644 webapp/src/api/generated/api.ts create mode 100644 webapp/src/api/generated/configuration.ts create mode 100644 webapp/src/api/generated/custom.d.ts create mode 100644 webapp/src/api/generated/index.ts create mode 100644 webapp/src/api/generated/mappings.ts create mode 100644 webapp/src/api/index.ts diff --git a/swagger.yml b/swagger.yml new file mode 100644 index 0000000..1fdac7b --- /dev/null +++ b/swagger.yml @@ -0,0 +1,186 @@ +swagger: '2.0' + +info: + version: '1.0.0' + title: XArcade XInput Rest API + +host: 10.0.1.2:32123 +schemes: +- http +basePath: /api +consumes: + - application/json + +tags: +- name: keyboard + description: Keyboard mapping and service +- name: controller + description: Add / remove fake controllers + +paths: + /keyboard/start: + post: + summary: Start watching keyboard for input + tags: + - keyboard + consumes: + - application/json + responses: + 200: + description: Ok + + /keyboard/stop: + post: + summary: Stop watching keyboard for input + tags: + - keyboard + responses: + 200: + description: Ok + + /keyboard/mapping: + get: + tags: + - keyboard + summary: Get keyboard mapping list and status + responses: + 200: + description: Ok + schema: + type: object + properties: + currentMapping: + type: string + mappings: + type: object + additionalProperties: + type: string + example: + Mapping 1: '{ "Space": [0, "A"] }' + Mapping 2: '{ "Space": [0, "X"] }' + + post: + summary: Update a mapping + tags: + - keyboard + parameters: + - name: body + in: body + schema: + type: object + properties: + name: + type: string + description: Mapping name + mapping: + type: string + description: JSON contents of mapping + responses: + 200: + description: Ok + 500: + description: Error + schema: + $ref: '#/definitions/BasicError' + + delete: + summary: Delete a mapping + tags: + - keyboard + parameters: + - name: body + in: body + schema: + type: string + description: Name of mapping to delete + responses: + 200: + description: Ok + 500: + description: Error + schema: + $ref: '#/definitions/BasicError' + + /keyboard/mapping/current: + post: + summary: Change mapping + tags: + - keyboard + parameters: + - name: body + in: body + schema: + type: string + description: Name of mapping to switch to + responses: + 200: + description: Ok + 500: + description: Error + schema: + $ref: '#/definitions/BasicError' + + /keyboard/mapping/rename: + post: + summary: Rename a mapping + tags: + - keyboard + parameters: + - name: body + in: body + schema: + type: object + properties: + name: + type: string + newName: + type: string + responses: + 200: + description: Ok + 500: + description: Error + schema: + $ref: '#/definitions/BasicError' + + /controller/start: + post: + summary: Add fake controllers + tags: + - controller + responses: + 200: + description: Ok + + /controller/stop: + post: + summary: Remove fake controllers + tags: + - controller + responses: + 200: + description: Ok + + /status: + get: + summary: Get running status + responses: + 200: + description: Ok + schema: + type: object + properties: + isControllerRunning: + type: boolean + isKeyboardRunning: + type: boolean + hostname: + type: string + +definitions: + BasicError: + type: object + properties: + error: + type: string + description: Error message diff --git a/webapp/src/api/generated/api.ts b/webapp/src/api/generated/api.ts new file mode 100644 index 0000000..270f53f --- /dev/null +++ b/webapp/src/api/generated/api.ts @@ -0,0 +1,932 @@ +// tslint:disable +/// +/** + * XArcade XInput Rest API + * No description provided (generated by Swagger Codegen https://github.com/swagger-api/swagger-codegen) + * + * OpenAPI spec version: 1.0.0 + * + * + * NOTE: This class is auto generated by the swagger code generator program. + * https://github.com/swagger-api/swagger-codegen.git + * Do not edit the class manually. + */ + + +import * as url from "url"; +import * as portableFetch from "portable-fetch"; +import { Configuration } from "./configuration"; + +const BASE_PATH = "http://localhost:32123/api".replace(/\/+$/, ""); + +/** + * + * @export + */ +export const COLLECTION_FORMATS = { + csv: ",", + ssv: " ", + tsv: "\t", + pipes: "|", +}; + +/** + * + * @export + * @interface FetchAPI + */ +export interface FetchAPI { + (url: string, init?: any): Promise; +} + +/** + * + * @export + * @interface FetchArgs + */ +export interface FetchArgs { + url: string; + options: any; +} + +/** + * + * @export + * @class BaseAPI + */ +export class BaseAPI { + protected configuration: Configuration; + + constructor(configuration?: Configuration, protected basePath: string = BASE_PATH, protected fetch: FetchAPI = portableFetch) { + if (configuration) { + this.configuration = configuration; + this.basePath = configuration.basePath || this.basePath; + } + } +}; + +/** + * + * @export + * @class RequiredError + * @extends {Error} + */ +export class RequiredError extends Error { + name: "RequiredError" + constructor(public field: string, msg?: string) { + super(msg); + } +} + +/** + * + * @export + * @interface BasicError + */ +export interface BasicError { + /** + * Error message + * @type {string} + * @memberof BasicError + */ + error?: string; +} + +/** + * + * @export + * @interface Body + */ +export interface Body { + /** + * Mapping name + * @type {string} + * @memberof Body + */ + name?: string; + /** + * JSON contents of mapping + * @type {string} + * @memberof Body + */ + mapping?: string; +} + +/** + * + * @export + * @interface Body1 + */ +export interface Body1 { + /** + * + * @type {string} + * @memberof Body1 + */ + name?: string; + /** + * + * @type {string} + * @memberof Body1 + */ + newName?: string; +} + +/** + * + * @export + * @interface InlineResponse200 + */ +export interface InlineResponse200 { + /** + * + * @type {string} + * @memberof InlineResponse200 + */ + currentMapping?: string; + /** + * + * @type {{ [key: string]: string; }} + * @memberof InlineResponse200 + */ + mappings?: { [key: string]: string; }; +} + +/** + * + * @export + * @interface InlineResponse2001 + */ +export interface InlineResponse2001 { + /** + * + * @type {boolean} + * @memberof InlineResponse2001 + */ + isControllerRunning?: boolean; + /** + * + * @type {boolean} + * @memberof InlineResponse2001 + */ + isKeyboardRunning?: boolean; + /** + * + * @type {string} + * @memberof InlineResponse2001 + */ + hostname?: string; +} + + +/** + * ControllerApi - fetch parameter creator + * @export + */ +export const ControllerApiFetchParamCreator = function (configuration?: Configuration) { + return { + /** + * + * @summary Add fake controllers + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + controllerStartPost(options: any = {}): FetchArgs { + const localVarPath = `/controller/start`; + const localVarUrlObj = url.parse(localVarPath, true); + const localVarRequestOptions = Object.assign({ method: 'POST' }, options); + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + localVarUrlObj.query = Object.assign({}, localVarUrlObj.query, localVarQueryParameter, options.query); + // fix override query string Detail: https://stackoverflow.com/a/7517673/1077943 + delete localVarUrlObj.search; + localVarRequestOptions.headers = Object.assign({}, localVarHeaderParameter, options.headers); + + return { + url: url.format(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @summary Remove fake controllers + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + controllerStopPost(options: any = {}): FetchArgs { + const localVarPath = `/controller/stop`; + const localVarUrlObj = url.parse(localVarPath, true); + const localVarRequestOptions = Object.assign({ method: 'POST' }, options); + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + localVarUrlObj.query = Object.assign({}, localVarUrlObj.query, localVarQueryParameter, options.query); + // fix override query string Detail: https://stackoverflow.com/a/7517673/1077943 + delete localVarUrlObj.search; + localVarRequestOptions.headers = Object.assign({}, localVarHeaderParameter, options.headers); + + return { + url: url.format(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * ControllerApi - functional programming interface + * @export + */ +export const ControllerApiFp = function(configuration?: Configuration) { + return { + /** + * + * @summary Add fake controllers + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + controllerStartPost(options?: any): (fetch?: FetchAPI, basePath?: string) => Promise { + const localVarFetchArgs = ControllerApiFetchParamCreator(configuration).controllerStartPost(options); + return (fetch: FetchAPI = portableFetch, basePath: string = BASE_PATH) => { + return fetch(basePath + localVarFetchArgs.url, localVarFetchArgs.options).then((response) => { + if (response.status >= 200 && response.status < 300) { + return response; + } else { + throw response; + } + }); + }; + }, + /** + * + * @summary Remove fake controllers + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + controllerStopPost(options?: any): (fetch?: FetchAPI, basePath?: string) => Promise { + const localVarFetchArgs = ControllerApiFetchParamCreator(configuration).controllerStopPost(options); + return (fetch: FetchAPI = portableFetch, basePath: string = BASE_PATH) => { + return fetch(basePath + localVarFetchArgs.url, localVarFetchArgs.options).then((response) => { + if (response.status >= 200 && response.status < 300) { + return response; + } else { + throw response; + } + }); + }; + }, + } +}; + +/** + * ControllerApi - factory interface + * @export + */ +export const ControllerApiFactory = function (configuration?: Configuration, fetch?: FetchAPI, basePath?: string) { + return { + /** + * + * @summary Add fake controllers + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + controllerStartPost(options?: any) { + return ControllerApiFp(configuration).controllerStartPost(options)(fetch, basePath); + }, + /** + * + * @summary Remove fake controllers + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + controllerStopPost(options?: any) { + return ControllerApiFp(configuration).controllerStopPost(options)(fetch, basePath); + }, + }; +}; + +/** + * ControllerApi - object-oriented interface + * @export + * @class ControllerApi + * @extends {BaseAPI} + */ +export class ControllerApi extends BaseAPI { + /** + * + * @summary Add fake controllers + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ControllerApi + */ + public controllerStartPost(options?: any) { + return ControllerApiFp(this.configuration).controllerStartPost(options)(this.fetch, this.basePath); + } + + /** + * + * @summary Remove fake controllers + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ControllerApi + */ + public controllerStopPost(options?: any) { + return ControllerApiFp(this.configuration).controllerStopPost(options)(this.fetch, this.basePath); + } + +} + +/** + * DefaultApi - fetch parameter creator + * @export + */ +export const DefaultApiFetchParamCreator = function (configuration?: Configuration) { + return { + /** + * + * @summary Get running status + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + statusGet(options: any = {}): FetchArgs { + const localVarPath = `/status`; + const localVarUrlObj = url.parse(localVarPath, true); + const localVarRequestOptions = Object.assign({ method: 'GET' }, options); + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + localVarUrlObj.query = Object.assign({}, localVarUrlObj.query, localVarQueryParameter, options.query); + // fix override query string Detail: https://stackoverflow.com/a/7517673/1077943 + delete localVarUrlObj.search; + localVarRequestOptions.headers = Object.assign({}, localVarHeaderParameter, options.headers); + + return { + url: url.format(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * DefaultApi - functional programming interface + * @export + */ +export const DefaultApiFp = function(configuration?: Configuration) { + return { + /** + * + * @summary Get running status + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + statusGet(options?: any): (fetch?: FetchAPI, basePath?: string) => Promise { + const localVarFetchArgs = DefaultApiFetchParamCreator(configuration).statusGet(options); + return (fetch: FetchAPI = portableFetch, basePath: string = BASE_PATH) => { + return fetch(basePath + localVarFetchArgs.url, localVarFetchArgs.options).then((response) => { + if (response.status >= 200 && response.status < 300) { + return response.json(); + } else { + throw response; + } + }); + }; + }, + } +}; + +/** + * DefaultApi - factory interface + * @export + */ +export const DefaultApiFactory = function (configuration?: Configuration, fetch?: FetchAPI, basePath?: string) { + return { + /** + * + * @summary Get running status + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + statusGet(options?: any) { + return DefaultApiFp(configuration).statusGet(options)(fetch, basePath); + }, + }; +}; + +/** + * DefaultApi - object-oriented interface + * @export + * @class DefaultApi + * @extends {BaseAPI} + */ +export class DefaultApi extends BaseAPI { + /** + * + * @summary Get running status + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof DefaultApi + */ + public statusGet(options?: any) { + return DefaultApiFp(this.configuration).statusGet(options)(this.fetch, this.basePath); + } + +} + +/** + * KeyboardApi - fetch parameter creator + * @export + */ +export const KeyboardApiFetchParamCreator = function (configuration?: Configuration) { + return { + /** + * + * @summary Change mapping + * @param {string} [body] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + keyboardMappingCurrentPost(body?: string, options: any = {}): FetchArgs { + const localVarPath = `/keyboard/mapping/current`; + const localVarUrlObj = url.parse(localVarPath, true); + const localVarRequestOptions = Object.assign({ method: 'POST' }, options); + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + localVarUrlObj.query = Object.assign({}, localVarUrlObj.query, localVarQueryParameter, options.query); + // fix override query string Detail: https://stackoverflow.com/a/7517673/1077943 + delete localVarUrlObj.search; + localVarRequestOptions.headers = Object.assign({}, localVarHeaderParameter, options.headers); + const needsSerialization = ("string" !== "string") || localVarRequestOptions.headers['Content-Type'] === 'application/json'; + localVarRequestOptions.body = needsSerialization ? JSON.stringify(body || {}) : (body || ""); + + return { + url: url.format(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @summary Delete a mapping + * @param {string} [body] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + keyboardMappingDelete(body?: string, options: any = {}): FetchArgs { + const localVarPath = `/keyboard/mapping`; + const localVarUrlObj = url.parse(localVarPath, true); + const localVarRequestOptions = Object.assign({ method: 'DELETE' }, options); + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + localVarUrlObj.query = Object.assign({}, localVarUrlObj.query, localVarQueryParameter, options.query); + // fix override query string Detail: https://stackoverflow.com/a/7517673/1077943 + delete localVarUrlObj.search; + localVarRequestOptions.headers = Object.assign({}, localVarHeaderParameter, options.headers); + const needsSerialization = ("string" !== "string") || localVarRequestOptions.headers['Content-Type'] === 'application/json'; + localVarRequestOptions.body = needsSerialization ? JSON.stringify(body || {}) : (body || ""); + + return { + url: url.format(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @summary Get keyboard mapping list and status + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + keyboardMappingGet(options: any = {}): FetchArgs { + const localVarPath = `/keyboard/mapping`; + const localVarUrlObj = url.parse(localVarPath, true); + const localVarRequestOptions = Object.assign({ method: 'GET' }, options); + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + localVarUrlObj.query = Object.assign({}, localVarUrlObj.query, localVarQueryParameter, options.query); + // fix override query string Detail: https://stackoverflow.com/a/7517673/1077943 + delete localVarUrlObj.search; + localVarRequestOptions.headers = Object.assign({}, localVarHeaderParameter, options.headers); + + return { + url: url.format(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @summary Update a mapping + * @param {Body} [body] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + keyboardMappingPost(body?: Body, options: any = {}): FetchArgs { + const localVarPath = `/keyboard/mapping`; + const localVarUrlObj = url.parse(localVarPath, true); + const localVarRequestOptions = Object.assign({ method: 'POST' }, options); + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + localVarUrlObj.query = Object.assign({}, localVarUrlObj.query, localVarQueryParameter, options.query); + // fix override query string Detail: https://stackoverflow.com/a/7517673/1077943 + delete localVarUrlObj.search; + localVarRequestOptions.headers = Object.assign({}, localVarHeaderParameter, options.headers); + const needsSerialization = ("Body" !== "string") || localVarRequestOptions.headers['Content-Type'] === 'application/json'; + localVarRequestOptions.body = needsSerialization ? JSON.stringify(body || {}) : (body || ""); + + return { + url: url.format(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @summary Rename a mapping + * @param {Body1} [body] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + keyboardMappingRenamePost(body?: Body1, options: any = {}): FetchArgs { + const localVarPath = `/keyboard/mapping/rename`; + const localVarUrlObj = url.parse(localVarPath, true); + const localVarRequestOptions = Object.assign({ method: 'POST' }, options); + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + localVarUrlObj.query = Object.assign({}, localVarUrlObj.query, localVarQueryParameter, options.query); + // fix override query string Detail: https://stackoverflow.com/a/7517673/1077943 + delete localVarUrlObj.search; + localVarRequestOptions.headers = Object.assign({}, localVarHeaderParameter, options.headers); + const needsSerialization = ("Body1" !== "string") || localVarRequestOptions.headers['Content-Type'] === 'application/json'; + localVarRequestOptions.body = needsSerialization ? JSON.stringify(body || {}) : (body || ""); + // debugger + + return { + url: url.format(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @summary Start watching keyboard for input + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + keyboardStartPost(options: any = {}): FetchArgs { + const localVarPath = `/keyboard/start`; + const localVarUrlObj = url.parse(localVarPath, true); + const localVarRequestOptions = Object.assign({ method: 'POST' }, options); + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + localVarUrlObj.query = Object.assign({}, localVarUrlObj.query, localVarQueryParameter, options.query); + // fix override query string Detail: https://stackoverflow.com/a/7517673/1077943 + delete localVarUrlObj.search; + localVarRequestOptions.headers = Object.assign({}, localVarHeaderParameter, options.headers); + + return { + url: url.format(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @summary Stop watching keyboard for input + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + keyboardStopPost(options: any = {}): FetchArgs { + const localVarPath = `/keyboard/stop`; + const localVarUrlObj = url.parse(localVarPath, true); + const localVarRequestOptions = Object.assign({ method: 'POST' }, options); + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + localVarUrlObj.query = Object.assign({}, localVarUrlObj.query, localVarQueryParameter, options.query); + // fix override query string Detail: https://stackoverflow.com/a/7517673/1077943 + delete localVarUrlObj.search; + localVarRequestOptions.headers = Object.assign({}, localVarHeaderParameter, options.headers); + + return { + url: url.format(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * KeyboardApi - functional programming interface + * @export + */ +export const KeyboardApiFp = function(configuration?: Configuration) { + return { + /** + * + * @summary Change mapping + * @param {string} [body] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + keyboardMappingCurrentPost(body?: string, options?: any): (fetch?: FetchAPI, basePath?: string) => Promise { + const localVarFetchArgs = KeyboardApiFetchParamCreator(configuration).keyboardMappingCurrentPost(body, options); + return (fetch: FetchAPI = portableFetch, basePath: string = BASE_PATH) => { + return fetch(basePath + localVarFetchArgs.url, localVarFetchArgs.options).then((response) => { + if (response.status >= 200 && response.status < 300) { + return response; + } else { + throw response; + } + }); + }; + }, + /** + * + * @summary Delete a mapping + * @param {string} [body] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + keyboardMappingDelete(body?: string, options?: any): (fetch?: FetchAPI, basePath?: string) => Promise { + const localVarFetchArgs = KeyboardApiFetchParamCreator(configuration).keyboardMappingDelete(body, options); + return (fetch: FetchAPI = portableFetch, basePath: string = BASE_PATH) => { + return fetch(basePath + localVarFetchArgs.url, localVarFetchArgs.options).then((response) => { + if (response.status >= 200 && response.status < 300) { + return response; + } else { + throw response; + } + }); + }; + }, + /** + * + * @summary Get keyboard mapping list and status + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + keyboardMappingGet(options?: any): (fetch?: FetchAPI, basePath?: string) => Promise { + const localVarFetchArgs = KeyboardApiFetchParamCreator(configuration).keyboardMappingGet(options); + return (fetch: FetchAPI = portableFetch, basePath: string = BASE_PATH) => { + return fetch(basePath + localVarFetchArgs.url, localVarFetchArgs.options).then((response) => { + if (response.status >= 200 && response.status < 300) { + return response.json(); + } else { + throw response; + } + }); + }; + }, + /** + * + * @summary Update a mapping + * @param {Body} [body] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + keyboardMappingPost(body?: Body, options?: any): (fetch?: FetchAPI, basePath?: string) => Promise { + const localVarFetchArgs = KeyboardApiFetchParamCreator(configuration).keyboardMappingPost(body, options); + return (fetch: FetchAPI = portableFetch, basePath: string = BASE_PATH) => { + return fetch(basePath + localVarFetchArgs.url, localVarFetchArgs.options).then((response) => { + if (response.status >= 200 && response.status < 300) { + return response; + } else { + throw response; + } + }); + }; + }, + /** + * + * @summary Rename a mapping + * @param {Body1} [body] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + keyboardMappingRenamePost(body?: Body1, options?: any): (fetch?: FetchAPI, basePath?: string) => Promise { + const localVarFetchArgs = KeyboardApiFetchParamCreator(configuration).keyboardMappingRenamePost(body, options); + return (fetch: FetchAPI = portableFetch, basePath: string = BASE_PATH) => { + return fetch(basePath + localVarFetchArgs.url, localVarFetchArgs.options).then((response) => { + if (response.status >= 200 && response.status < 300) { + return response; + } else { + throw response; + } + }); + }; + }, + /** + * + * @summary Start watching keyboard for input + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + keyboardStartPost(options?: any): (fetch?: FetchAPI, basePath?: string) => Promise { + const localVarFetchArgs = KeyboardApiFetchParamCreator(configuration).keyboardStartPost(options); + return (fetch: FetchAPI = portableFetch, basePath: string = BASE_PATH) => { + return fetch(basePath + localVarFetchArgs.url, localVarFetchArgs.options).then((response) => { + if (response.status >= 200 && response.status < 300) { + return response; + } else { + throw response; + } + }); + }; + }, + /** + * + * @summary Stop watching keyboard for input + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + keyboardStopPost(options?: any): (fetch?: FetchAPI, basePath?: string) => Promise { + const localVarFetchArgs = KeyboardApiFetchParamCreator(configuration).keyboardStopPost(options); + return (fetch: FetchAPI = portableFetch, basePath: string = BASE_PATH) => { + return fetch(basePath + localVarFetchArgs.url, localVarFetchArgs.options).then((response) => { + if (response.status >= 200 && response.status < 300) { + return response; + } else { + throw response; + } + }); + }; + }, + } +}; + +/** + * KeyboardApi - factory interface + * @export + */ +export const KeyboardApiFactory = function (configuration?: Configuration, fetch?: FetchAPI, basePath?: string) { + return { + /** + * + * @summary Change mapping + * @param {string} [body] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + keyboardMappingCurrentPost(body?: string, options?: any) { + return KeyboardApiFp(configuration).keyboardMappingCurrentPost(body, options)(fetch, basePath); + }, + /** + * + * @summary Delete a mapping + * @param {string} [body] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + keyboardMappingDelete(body?: string, options?: any) { + return KeyboardApiFp(configuration).keyboardMappingDelete(body, options)(fetch, basePath); + }, + /** + * + * @summary Get keyboard mapping list and status + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + keyboardMappingGet(options?: any) { + return KeyboardApiFp(configuration).keyboardMappingGet(options)(fetch, basePath); + }, + /** + * + * @summary Update a mapping + * @param {Body} [body] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + keyboardMappingPost(body?: Body, options?: any) { + return KeyboardApiFp(configuration).keyboardMappingPost(body, options)(fetch, basePath); + }, + /** + * + * @summary Rename a mapping + * @param {Body1} [body] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + keyboardMappingRenamePost(body?: Body1, options?: any) { + return KeyboardApiFp(configuration).keyboardMappingRenamePost(body, options)(fetch, basePath); + }, + /** + * + * @summary Start watching keyboard for input + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + keyboardStartPost(options?: any) { + return KeyboardApiFp(configuration).keyboardStartPost(options)(fetch, basePath); + }, + /** + * + * @summary Stop watching keyboard for input + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + keyboardStopPost(options?: any) { + return KeyboardApiFp(configuration).keyboardStopPost(options)(fetch, basePath); + }, + }; +}; + +/** + * KeyboardApi - object-oriented interface + * @export + * @class KeyboardApi + * @extends {BaseAPI} + */ +export class KeyboardApi extends BaseAPI { + /** + * + * @summary Change mapping + * @param {} [body] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof KeyboardApi + */ + public keyboardMappingCurrentPost(body?: string, options?: any) { + return KeyboardApiFp(this.configuration).keyboardMappingCurrentPost(body, options)(this.fetch, this.basePath); + } + + /** + * + * @summary Delete a mapping + * @param {} [body] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof KeyboardApi + */ + public keyboardMappingDelete(body?: string, options?: any) { + return KeyboardApiFp(this.configuration).keyboardMappingDelete(body, options)(this.fetch, this.basePath); + } + + /** + * + * @summary Get keyboard mapping list and status + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof KeyboardApi + */ + public keyboardMappingGet(options?: any) { + return KeyboardApiFp(this.configuration).keyboardMappingGet(options)(this.fetch, this.basePath); + } + + /** + * + * @summary Update a mapping + * @param {} [body] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof KeyboardApi + */ + public keyboardMappingPost(body?: Body, options?: any) { + return KeyboardApiFp(this.configuration).keyboardMappingPost(body, options)(this.fetch, this.basePath); + } + + /** + * + * @summary Rename a mapping + * @param {} [body] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof KeyboardApi + */ + public keyboardMappingRenamePost(body?: Body1, options?: any) { + return KeyboardApiFp(this.configuration).keyboardMappingRenamePost(body, options)(this.fetch, this.basePath); + } + + /** + * + * @summary Start watching keyboard for input + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof KeyboardApi + */ + public keyboardStartPost(options?: any) { + return KeyboardApiFp(this.configuration).keyboardStartPost(options)(this.fetch, this.basePath); + } + + /** + * + * @summary Stop watching keyboard for input + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof KeyboardApi + */ + public keyboardStopPost(options?: any) { + return KeyboardApiFp(this.configuration).keyboardStopPost(options)(this.fetch, this.basePath); + } + +} + diff --git a/webapp/src/api/generated/configuration.ts b/webapp/src/api/generated/configuration.ts new file mode 100644 index 0000000..520c0bc --- /dev/null +++ b/webapp/src/api/generated/configuration.ts @@ -0,0 +1,66 @@ +// tslint:disable +/** + * XArcade XInput Rest API + * No description provided (generated by Swagger Codegen https://github.com/swagger-api/swagger-codegen) + * + * OpenAPI spec version: 1.0.0 + * + * + * NOTE: This class is auto generated by the swagger code generator program. + * https://github.com/swagger-api/swagger-codegen.git + * Do not edit the class manually. + */ + + +export interface ConfigurationParameters { + apiKey?: string | ((name: string) => string); + username?: string; + password?: string; + accessToken?: string | ((name: string, scopes?: string[]) => string); + basePath?: string; +} + +export class Configuration { + /** + * parameter for apiKey security + * @param name security name + * @memberof Configuration + */ + apiKey?: string | ((name: string) => string); + /** + * parameter for basic security + * + * @type {string} + * @memberof Configuration + */ + username?: string; + /** + * parameter for basic security + * + * @type {string} + * @memberof Configuration + */ + password?: string; + /** + * parameter for oauth2 security + * @param name security name + * @param scopes oauth2 scope + * @memberof Configuration + */ + accessToken?: string | ((name: string, scopes?: string[]) => string); + /** + * override base path + * + * @type {string} + * @memberof Configuration + */ + basePath?: string; + + constructor(param: ConfigurationParameters = {}) { + this.apiKey = param.apiKey; + this.username = param.username; + this.password = param.password; + this.accessToken = param.accessToken; + this.basePath = param.basePath; + } +} diff --git a/webapp/src/api/generated/custom.d.ts b/webapp/src/api/generated/custom.d.ts new file mode 100644 index 0000000..02f9695 --- /dev/null +++ b/webapp/src/api/generated/custom.d.ts @@ -0,0 +1 @@ +declare module 'portable-fetch'; \ No newline at end of file diff --git a/webapp/src/api/generated/index.ts b/webapp/src/api/generated/index.ts new file mode 100644 index 0000000..6485beb --- /dev/null +++ b/webapp/src/api/generated/index.ts @@ -0,0 +1,16 @@ +// tslint:disable +/** + * XArcade XInput Rest API + * No description provided (generated by Swagger Codegen https://github.com/swagger-api/swagger-codegen) + * + * OpenAPI spec version: 1.0.0 + * + * + * NOTE: This class is auto generated by the swagger code generator program. + * https://github.com/swagger-api/swagger-codegen.git + * Do not edit the class manually. + */ + + +export * from "./api"; +export * from "./configuration"; diff --git a/webapp/src/api/generated/mappings.ts b/webapp/src/api/generated/mappings.ts new file mode 100644 index 0000000..e69de29 diff --git a/webapp/src/api/index.ts b/webapp/src/api/index.ts new file mode 100644 index 0000000..daa5fb9 --- /dev/null +++ b/webapp/src/api/index.ts @@ -0,0 +1,10 @@ +import * as api from './generated/api' + +const queryParams = new URLSearchParams(window.location.search) +const basePath = queryParams.get('API_URL') || undefined + +export default { + controller: api.ControllerApiFactory(undefined, undefined, basePath), + keyboard: api.KeyboardApiFactory(undefined, undefined, basePath), + default: api.DefaultApiFactory(undefined, undefined, basePath), +} From 69301cf1696ea3bd2ec38cd74ad1022c308ddf72 Mon Sep 17 00:00:00 2001 From: Mike Wyatt Date: Sat, 14 Jul 2018 21:54:49 -0300 Subject: [PATCH 05/16] random stuff from the upgrade --- webapp/.editorconfig | 9 +++++++ webapp/.gitignore | 7 ++++-- webapp/package.json | 49 +++++++++++++++++++++++------------- webapp/public/favicon.ico | Bin 0 -> 3870 bytes webapp/public/index.html | 14 +++++++++-- webapp/public/manifest.json | 15 +++++++++++ 6 files changed, 72 insertions(+), 22 deletions(-) create mode 100644 webapp/.editorconfig create mode 100644 webapp/public/favicon.ico create mode 100644 webapp/public/manifest.json diff --git a/webapp/.editorconfig b/webapp/.editorconfig new file mode 100644 index 0000000..a3ad0dc --- /dev/null +++ b/webapp/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = false \ No newline at end of file diff --git a/webapp/.gitignore b/webapp/.gitignore index 927d17b..d30f40e 100644 --- a/webapp/.gitignore +++ b/webapp/.gitignore @@ -11,8 +11,11 @@ # misc .DS_Store -.env +.env.local +.env.development.local +.env.test.local +.env.production.local + npm-debug.log* yarn-debug.log* yarn-error.log* - diff --git a/webapp/package.json b/webapp/package.json index 2e860f5..7a62937 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -4,24 +4,37 @@ "version": "0.1.0", "private": true, "dependencies": {}, - "devDependencies": { - "brace": "^0.10.0", - "material-ui": "next", - "react": "^15.5.4", - "react-ace": "^4.2.1", - "react-dom": "^15.5.4", - "react-redux": "^5.0.4", - "react-scripts": "0.9.5", - "react-tap-event-plugin": "^2.0.1", - "redux": "^3.6.0", - "redux-logger": "^3.0.1", - "redux-promise-middleware": "^4.2.0", - "redux-thunk": "^2.2.0" - }, "scripts": { - "start": "react-scripts start", - "build": "react-scripts build", - "test": "react-scripts test --env=jsdom", - "eject": "react-scripts eject" + "eject": "react-scripts-ts eject" + }, + "devDependencies": { + "@material-ui/core": "^1.4.0", + "@types/classnames": "^2.2.4", + "@types/enzyme": "^3.1.11", + "@types/enzyme-adapter-react-16": "^1.0.2", + "@types/jest": "^23.1.6", + "@types/node": "^10.5.2", + "@types/react": "^16.4.6", + "@types/react-dom": "^16.0.6", + "@types/react-redux": "^6.0.4", + "@types/webpack-env": "^1.13.6", + "brace": "^0.11.1", + "classnames": "^2.2.6", + "enzyme": "^3.3.0", + "enzyme-adapter-react-16": "^1.1.1", + "portable-fetch": "^3.0.0", + "react": "^16.4.1", + "react-ace": "^6.1.4", + "react-dom": "^16.4.1", + "react-monaco-editor": "^0.17.2", + "react-redux": "^5.0.7", + "react-scripts-ts": "^2.15.1", + "redux": "^4.0.0", + "redux-async-payload": "^1.0.9", + "redux-ts-helpers": "^1.0.5", + "reselect": "^3.0.1", + "tslint-config-sst": "^1.0.2", + "typescript": "^2.9.2", + "url-search-params-polyfill": "^4.0.1" } } diff --git a/webapp/public/favicon.ico b/webapp/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..a11777cc471a4344702741ab1c8a588998b1311a GIT binary patch literal 3870 zcma);c{J4h9>;%nil|2-o+rCuEF-(I%-F}ijC~o(k~HKAkr0)!FCj~d>`RtpD?8b; zXOC1OD!V*IsqUwzbMF1)-gEDD=A573Z-&G7^LoAC9|WO7Xc0Cx1g^Zu0u_SjAPB3vGa^W|sj)80f#V0@M_CAZTIO(t--xg= z!sii`1giyH7EKL_+Wi0ab<)&E_0KD!3Rp2^HNB*K2@PHCs4PWSA32*-^7d{9nH2_E zmC{C*N*)(vEF1_aMamw2A{ZH5aIDqiabnFdJ|y0%aS|64E$`s2ccV~3lR!u<){eS` z#^Mx6o(iP1Ix%4dv`t@!&Za-K@mTm#vadc{0aWDV*_%EiGK7qMC_(`exc>-$Gb9~W!w_^{*pYRm~G zBN{nA;cm^w$VWg1O^^<6vY`1XCD|s_zv*g*5&V#wv&s#h$xlUilPe4U@I&UXZbL z0)%9Uj&@yd03n;!7do+bfixH^FeZ-Ema}s;DQX2gY+7g0s(9;`8GyvPY1*vxiF&|w z>!vA~GA<~JUqH}d;DfBSi^IT*#lrzXl$fNpq0_T1tA+`A$1?(gLb?e#0>UELvljtQ zK+*74m0jn&)5yk8mLBv;=@}c{t0ztT<v;Avck$S6D`Z)^c0(jiwKhQsn|LDRY&w(Fmi91I7H6S;b0XM{e zXp0~(T@k_r-!jkLwd1_Vre^v$G4|kh4}=Gi?$AaJ)3I+^m|Zyj#*?Kp@w(lQdJZf4 z#|IJW5z+S^e9@(6hW6N~{pj8|NO*>1)E=%?nNUAkmv~OY&ZV;m-%?pQ_11)hAr0oAwILrlsGawpxx4D43J&K=n+p3WLnlDsQ$b(9+4 z?mO^hmV^F8MV{4Lx>(Q=aHhQ1){0d*(e&s%G=i5rq3;t{JC zmgbn5Nkl)t@fPH$v;af26lyhH!k+#}_&aBK4baYPbZy$5aFx4}ka&qxl z$=Rh$W;U)>-=S-0=?7FH9dUAd2(q#4TCAHky!$^~;Dz^j|8_wuKc*YzfdAht@Q&ror?91Dm!N03=4=O!a)I*0q~p0g$Fm$pmr$ zb;wD;STDIi$@M%y1>p&_>%?UP($15gou_ue1u0!4(%81;qcIW8NyxFEvXpiJ|H4wz z*mFT(qVx1FKufG11hByuX%lPk4t#WZ{>8ka2efjY`~;AL6vWyQKpJun2nRiZYDij$ zP>4jQXPaP$UC$yIVgGa)jDV;F0l^n(V=HMRB5)20V7&r$jmk{UUIe zVjKroK}JAbD>B`2cwNQ&GDLx8{pg`7hbA~grk|W6LgiZ`8y`{Iq0i>t!3p2}MS6S+ zO_ruKyAElt)rdS>CtF7j{&6rP-#c=7evGMt7B6`7HG|-(WL`bDUAjyn+k$mx$CH;q2Dz4x;cPP$hW=`pFfLO)!jaCL@V2+F)So3}vg|%O*^T1j>C2lx zsURO-zIJC$^$g2byVbRIo^w>UxK}74^TqUiRR#7s_X$e)$6iYG1(PcW7un-va-S&u zHk9-6Zn&>T==A)lM^D~bk{&rFzCi35>UR!ZjQkdSiNX*-;l4z9j*7|q`TBl~Au`5& z+c)*8?#-tgUR$Zd%Q3bs96w6k7q@#tUn`5rj+r@_sAVVLqco|6O{ILX&U-&-cbVa3 zY?ngHR@%l{;`ri%H*0EhBWrGjv!LE4db?HEWb5mu*t@{kv|XwK8?npOshmzf=vZA@ zVSN9sL~!sn?r(AK)Q7Jk2(|M67Uy3I{eRy z_l&Y@A>;vjkWN5I2xvFFTLX0i+`{qz7C_@bo`ZUzDugfq4+>a3?1v%)O+YTd6@Ul7 zAfLfm=nhZ`)P~&v90$&UcF+yXm9sq!qCx3^9gzIcO|Y(js^Fj)Rvq>nQAHI92ap=P z10A4@prk+AGWCb`2)dQYFuR$|H6iDE8p}9a?#nV2}LBCoCf(Xi2@szia7#gY>b|l!-U`c}@ zLdhvQjc!BdLJvYvzzzngnw51yRYCqh4}$oRCy-z|v3Hc*d|?^Wj=l~18*E~*cR_kU z{XsxM1i{V*4GujHQ3DBpl2w4FgFR48Nma@HPgnyKoIEY-MqmMeY=I<%oG~l!f<+FN z1ZY^;10j4M4#HYXP zw5eJpA_y(>uLQ~OucgxDLuf}fVs272FaMxhn4xnDGIyLXnw>Xsd^J8XhcWIwIoQ9} z%FoSJTAGW(SRGwJwb=@pY7r$uQRK3Zd~XbxU)ts!4XsJrCycrWSI?e!IqwqIR8+Jh zlRjZ`UO1I!BtJR_2~7AbkbSm%XQqxEPkz6BTGWx8e}nQ=w7bZ|eVP4?*Tb!$(R)iC z9)&%bS*u(lXqzitAN)Oo=&Ytn>%Hzjc<5liuPi>zC_nw;Z0AE3Y$Jao_Q90R-gl~5 z_xAb2J%eArrC1CN4G$}-zVvCqF1;H;abAu6G*+PDHSYFx@Tdbfox*uEd3}BUyYY-l zTfEsOqsi#f9^FoLO;ChK<554qkri&Av~SIM*{fEYRE?vH7pTAOmu2pz3X?Wn*!ROX ztd54huAk&mFBemMooL33RV-*1f0Q3_(7hl$<#*|WF9P!;r;4_+X~k~uKEqdzZ$5Al zV63XN@)j$FN#cCD;ek1R#l zv%pGrhB~KWgoCj%GT?%{@@o(AJGt*PG#l3i>lhmb_twKH^EYvacVY-6bsCl5*^~L0 zonm@lk2UvvTKr2RS%}T>^~EYqdL1q4nD%0n&Xqr^cK^`J5W;lRRB^R-O8b&HENO||mo0xaD+S=I8RTlIfVgqN@SXDr2&-)we--K7w= zJVU8?Z+7k9dy;s;^gDkQa`0nz6N{T?(A&Iz)2!DEecLyRa&FI!id#5Z7B*O2=PsR0 zEvc|8{NS^)!d)MDX(97Xw}m&kEO@5jqRaDZ!+%`wYOI<23q|&js`&o4xvjP7D_xv@ z5hEwpsp{HezI9!~6O{~)lLR@oF7?J7i>1|5a~UuoN=q&6N}EJPV_GD`&M*v8Y`^2j zKII*d_@Fi$+i*YEW+Hbzn{iQk~yP z>7N{S4)r*!NwQ`(qcN#8SRQsNK6>{)X12nbF`*7#ecO7I)Q$uZsV+xS4E7aUn+U(K baj7?x%VD!5Cxk2YbYLNVeiXvvpMCWYo=by@ literal 0 HcmV?d00001 diff --git a/webapp/public/index.html b/webapp/public/index.html index 44d22cf..d609447 100644 --- a/webapp/public/index.html +++ b/webapp/public/index.html @@ -2,12 +2,19 @@ - + + + + +