Add loading UI, add view transition animations.

Framework is in place to run initial load on app startup. These routines will be called
in Application's initLoad function. The loader will run for a min of 800ms to prevent it
from looking odd on the UI. This can be reduced/changed later if this turns out to not be
a concern.

Added an app reducer to redux. The loading state was initially going to be there. On further
inspection, it seemed better to have it as a state variable in the Application component. It
remains in the store for now as an example. The pattern is valid and will be used for other
proprties.

Added animations for view transitions. On v1, this was done with jquery. Here, we are using
react-transition-group. Got it working with a clever trick to store a workingView and use that
for rendering. When the currentView is changed by the redux store, the fade out transition will
start. Once the fade out completes, the workingView reference will be updated to the new view,
triggering the fade in.
This commit is contained in:
Daniel Scalzi 2020-05-26 01:50:55 -04:00
parent 18fbfe4289
commit d9394432d2
No known key found for this signature in database
GPG Key ID: D18EA3FB4B142A57
19 changed files with 372 additions and 28 deletions

View File

@ -57,7 +57,8 @@ builder.build({
'!{dist,.gitignore,.vscode,docs,dev-app-update.yml,.travis.yml,.nvmrc,.eslintrc.json,build.js}' '!{dist,.gitignore,.vscode,docs,dev-app-update.yml,.travis.yml,.nvmrc,.eslintrc.json,build.js}'
], ],
extraResources: [ extraResources: [
'libraries' 'libraries',
'static'
], ],
asar: true asar: true
} }

54
package-lock.json generated
View File

@ -1686,6 +1686,12 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"@types/lodash": {
"version": "4.14.152",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.152.tgz",
"integrity": "sha512-Vwf9YF2x1GE3WNeUMjT5bTHa2DqgUo87ocdgTScupY2JclZ5Nn7W2RLM/N0+oreexUk8uaVugR81NnTY/jNNXg==",
"dev": true
},
"@types/minimatch": { "@types/minimatch": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz",
@ -1740,6 +1746,15 @@
"redux": "^4.0.0" "redux": "^4.0.0"
} }
}, },
"@types/react-transition-group": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.0.tgz",
"integrity": "sha512-/QfLHGpu+2fQOqQaXh8MG9q03bFENooTb/it4jr5kKaZlDQfWvjqWZg48AwzPVMBHlRuTRAY7hRHCEOXz5kV6w==",
"dev": true,
"requires": {
"@types/react": "*"
}
},
"@types/request": { "@types/request": {
"version": "2.48.5", "version": "2.48.5",
"resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.5.tgz", "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.5.tgz",
@ -4464,6 +4479,33 @@
"utila": "~0.4" "utila": "~0.4"
} }
}, },
"dom-helpers": {
"version": "5.1.4",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.1.4.tgz",
"integrity": "sha512-TjMyeVUvNEnOnhzs6uAn9Ya47GmMo3qq7m+Lr/3ON0Rs5kHvb8I+SQYjLUSYn7qhEm0QjW0yrBkvz9yOrwwz1A==",
"dev": true,
"requires": {
"@babel/runtime": "^7.8.7",
"csstype": "^2.6.7"
},
"dependencies": {
"@babel/runtime": {
"version": "7.9.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.9.6.tgz",
"integrity": "sha512-64AF1xY3OAkFHqOb9s4jpgk1Mm5vDZ4L3acHvAml+53nO1XbXLuDodsVpO4OIUsmemlUHMxNdYMNJmsvOwLrvQ==",
"dev": true,
"requires": {
"regenerator-runtime": "^0.13.4"
}
},
"regenerator-runtime": {
"version": "0.13.5",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz",
"integrity": "sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA==",
"dev": true
}
}
},
"dom-serializer": { "dom-serializer": {
"version": "0.2.2", "version": "0.2.2",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.2.tgz", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.2.tgz",
@ -10831,6 +10873,18 @@
"react-is": "^16.9.0" "react-is": "^16.9.0"
} }
}, },
"react-transition-group": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.1.tgz",
"integrity": "sha512-Djqr7OQ2aPUiYurhPalTrVy9ddmFCCzwhqQmtN+J3+3DzLO209Fdr70QrN8Z3DsglWql6iY1lDWAfpFiBtuKGw==",
"dev": true,
"requires": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2"
}
},
"read-config-file": { "read-config-file": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/read-config-file/-/read-config-file-6.0.0.tgz", "resolved": "https://registry.npmjs.org/read-config-file/-/read-config-file-6.0.0.tgz",

View File

@ -37,6 +37,7 @@
"github-syntax-dark": "^0.5.0", "github-syntax-dark": "^0.5.0",
"got": "^11.1.4", "got": "^11.1.4",
"jquery": "^3.5.1", "jquery": "^3.5.1",
"lodash": "^4.17.15",
"moment": "^2.26.0", "moment": "^2.26.0",
"request": "^2.88.2", "request": "^2.88.2",
"semver": "^7.3.2", "semver": "^7.3.2",
@ -53,11 +54,13 @@
"@types/discord-rpc": "^3.0.4", "@types/discord-rpc": "^3.0.4",
"@types/fs-extra": "^9.0.1", "@types/fs-extra": "^9.0.1",
"@types/jquery": "^3.3.38", "@types/jquery": "^3.3.38",
"@types/lodash": "^4.14.152",
"@types/mocha": "^7.0.2", "@types/mocha": "^7.0.2",
"@types/node": "^12.12.42", "@types/node": "^12.12.42",
"@types/react": "^16.9.35", "@types/react": "^16.9.35",
"@types/react-dom": "^16.9.8", "@types/react-dom": "^16.9.8",
"@types/react-redux": "^7.1.9", "@types/react-redux": "^7.1.9",
"@types/react-transition-group": "^4.4.0",
"@types/request": "^2.48.5", "@types/request": "^2.48.5",
"@types/tar-fs": "^2.0.0", "@types/tar-fs": "^2.0.0",
"@types/triple-beam": "^1.3.1", "@types/triple-beam": "^1.3.1",
@ -79,6 +82,7 @@
"react-dom": "^16.13.0", "react-dom": "^16.13.0",
"react-hot-loader": "^4.12.21", "react-hot-loader": "^4.12.21",
"react-redux": "^7.2.0", "react-redux": "^7.2.0",
"react-transition-group": "^4.4.1",
"redux": "^4.0.5", "redux": "^4.0.5",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"ts-node": "^8.10.1", "ts-node": "^8.10.1",

View File

@ -1,4 +1,38 @@
.appWrapper { .appWrapper {
height: calc(100vh - 22px); height: calc(100vh - 22px);
background: linear-gradient(to top, rgba(0, 0, 0, 0.75) 0%, rgba(0, 0, 0, 0) 100%); background: linear-gradient(to top, rgba(0, 0, 0, 0.75) 0%, rgba(0, 0, 0, 0) 100%);
}
.loader-enter {
opacity: 0;
transform: scale(0.9);
}
.loader-enter-active {
opacity: 1;
transform: translateX(0);
transition: opacity 300ms, transform 300ms;
}
.loader-exit {
opacity: 1;
}
.loader-exit-active {
opacity: 0;
transform: scale(0.9);
transition: opacity 300ms, transform 300ms;
}
.appWrapper-enter {
opacity: 0;
}
.appWrapper-enter-active {
opacity: 1;
transition: opacity 500ms, transform 500ms;
}
.appWrapper-exit {
opacity: 1;
}
.appWrapper-exit-active {
opacity: 0;
transition: opacity 500ms, transform 500ms;
} }

View File

@ -6,18 +6,61 @@ import { connect } from 'react-redux'
import { View } from '../meta/Views' import { View } from '../meta/Views'
import Landing from './landing/Landing' import Landing from './landing/Landing'
import Login from './login/Login' import Login from './login/Login'
import Loader from './loader/Loader'
import Settings from './settings/Settings' import Settings from './settings/Settings'
import './Application.css' import './Application.css'
import { StoreType } from '../redux/store'
import { CSSTransition } from 'react-transition-group'
import { setCurrentView } from '../redux/actions/viewActions'
import { throttle } from 'lodash'
import { readdir } from 'fs-extra'
import { join } from 'path'
type ApplicationProps = { declare const __static: string
function setBackground(id: number) {
import(`../../../static/images/backgrounds/${id}.jpg`).then(mdl => {
document.body.style.backgroundImage = `url('${mdl.default}')`
})
}
interface ApplicationProps {
currentView: View currentView: View
} }
class Application extends React.Component<ApplicationProps> { interface ApplicationState {
loading: boolean
showMain: boolean
renderMain: boolean
workingView: View
}
const mapState = (state: StoreType) => {
return {
currentView: state.currentView
}
}
const mapDispatch = {
setView: (x: View) => setCurrentView(x)
}
class Application extends React.Component<ApplicationProps & typeof mapDispatch, ApplicationState> {
private bkid!: number
constructor(props: ApplicationProps & typeof mapDispatch) {
super(props)
this.state = {
loading: true,
showMain: false,
renderMain: false,
workingView: props.currentView
}
}
getViewElement(): JSX.Element { getViewElement(): JSX.Element {
switch(this.props.currentView) { switch(this.state.workingView) {
case View.WELCOME: case View.WELCOME:
return <> return <>
<Welcome /> <Welcome />
@ -38,21 +81,86 @@ class Application extends React.Component<ApplicationProps> {
} }
} }
render() { private updateWorkingView = throttle(() => {
this.setState({
...this.state,
workingView: this.props.currentView
})
}, 200)
private showMain = (): void => {
setBackground(this.bkid)
this.setState({
...this.state,
showMain: true
})
}
private initLoad = async (): Promise<void> => {
if(this.state.loading) {
const MIN_LOAD = 800
const start = Date.now()
this.bkid = Math.floor((Math.random() * (await readdir(join(__static, 'images', 'backgrounds'))).length))
const endLoad = () => {
this.setState({
...this.state,
loading: false
})
// TODO temp
setTimeout(() => {
this.props.setView(View.WELCOME)
}, 5000)
}
const diff = Date.now() - start
if(diff < MIN_LOAD) {
setTimeout(endLoad, MIN_LOAD-diff)
} else {
endLoad()
}
}
}
render(): JSX.Element {
return ( return (
<> <>
<Frame /> <Frame />
<div className="appWrapper"> <CSSTransition
{this.getViewElement()} in={this.state.showMain}
</div> appear={true}
timeout={500}
classNames="appWrapper"
unmountOnExit
>
<div className="appWrapper">
<CSSTransition
in={this.props.currentView == this.state.workingView}
appear={true}
timeout={500}
classNames="appWrapper"
unmountOnExit
onExited={this.updateWorkingView}
>
{this.getViewElement()}
</CSSTransition>
</div>
</CSSTransition>
<CSSTransition
in={this.state.loading}
appear={true}
timeout={300}
classNames="loader"
unmountOnExit
onEnter={this.initLoad}
onExited={this.showMain}
>
<Loader />
</CSSTransition>
</> </>
) )
} }
} }
const connected = connect((state: any) => ({ export default hot(connect<unknown, typeof mapDispatch>(mapState, mapDispatch)(Application))
currentView: state.currentView
}), undefined)(Application)
export default hot(connected)

View File

@ -0,0 +1,62 @@
/*******************************************************************************
* *
* Loading Element (app.ejs) *
* *
******************************************************************************/
/* Loading container, placed above everything. */
#loadingContainer {
position: absolute;
z-index: 400;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: calc(100% - 22px);
}
/* Loading content container. */
#loadingContent {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
/* Spinner container. */
#loadSpinnerContainer {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
/* Stationary image for the spinner. */
#loadCenterImage {
position: absolute;
width: 277px;
height: auto;
}
/* Rotating image for the spinner. */
#loadSpinnerImage {
width: 280px;
height: auto;
z-index: 400;
}
/* Rotating animation for the spinner. */
@keyframes rotating {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Class which is applied when the spinner image is spinning. */
.rotating {
animation: rotating 10s linear infinite;
}

View File

@ -0,0 +1,23 @@
import * as React from 'react'
import './Loader.css'
import LoadingSeal from '../../../../static/images/LoadingSeal.png'
import LoadingText from '../../../../static/images/LoadingText.png'
export default class Loader extends React.Component {
render(): JSX.Element {
return <>
<div id="loadingContainer">
<div id="loadingContent">
<div id="loadSpinnerContainer">
<img id="loadCenterImage" src={LoadingSeal} />
<img id="loadSpinnerImage" className="rotating" src={LoadingText} />
</div>
</div>
</div>
</>
}
}

View File

@ -19,7 +19,6 @@
align-items: center; align-items: center;
height: 100%; height: 100%;
width: 100%; width: 100%;
transition: filter 0.25s ease;
background: rgba(0, 0, 0, 0.50); background: rgba(0, 0, 0, 0.50);
} }

View File

@ -5,6 +5,7 @@
align-items: center; align-items: center;
height: 100%; height: 100%;
width: 100%; width: 100%;
background: rgba(0, 0, 0, 0.50);
} }
#welcomeContent { #welcomeContent {

View File

@ -28,6 +28,8 @@
/* TODO: Temp for development */ /* TODO: Temp for development */
body { body {
/* background-image: url('../../static/images/backgrounds/3.jpg'); */ /* background-image: url('../../static/images/backgrounds/3.jpg'); */
transition: background-image 1s ease;
background-image: url('data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD//gA+Q1JFQVRPUjogZ2QtanBlZyB2MS4wICh1c2luZyBJSkcgSlBFRyB2ODApLCBkZWZhdWx0IHF1YWxpdHkK/9sAQwAIBgYHBgUIBwcHCQkICgwUDQwLCwwZEhMPFB0aHx4dGhwcICQuJyAiLCMcHCg3KSwwMTQ0NB8nOT04MjwuMzQy/9sAQwEJCQkMCwwYDQ0YMiEcITIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIy/8AAEQgAPwBwAwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/aAAwDAQACEQMRAD8A8VooopDCiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKAP/9k=');
background-size: cover; background-size: cover;
} }

View File

@ -6,19 +6,6 @@ import './index.css'
import Application from './components/Application' import Application from './components/Application'
import { Provider } from 'react-redux' import { Provider } from 'react-redux'
import { readdirSync } from 'fs-extra'
import { join } from 'path'
declare const __static: string
function setBackground(id: number) {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const bk = require('../../static/images/backgrounds/' + id + '.jpg')
document.body.style.backgroundImage = `url('${bk.default}')`
}
const id = Math.floor((Math.random() * readdirSync(join(__static, 'images', 'backgrounds')).length))
setBackground(id)
// Create main element // Create main element
const mainElement = document.createElement('div') const mainElement = document.createElement('div')

View File

@ -0,0 +1,19 @@
import { Action } from 'redux'
export enum AppActionType {
ChangeLoadState = 'SET_LOADING'
}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface AppAction extends Action {}
export interface ChangeLoadStateAction extends AppAction {
payload: boolean
}
export function setLoadingState(state: boolean): ChangeLoadStateAction {
return {
type: AppActionType.ChangeLoadState,
payload: state
}
}

View File

@ -0,0 +1,24 @@
import { ChangeLoadStateAction, AppActionType, AppAction } from '../actions/appActions'
import { Reducer } from 'redux'
export interface AppState {
loading: boolean
}
const defaultAppState: AppState = {
loading: true
}
// TODO remove loading from global state. Keeping as an example...
const AppReducer: Reducer<AppState, AppAction> = (state = defaultAppState, action) => {
switch(action.type) {
case AppActionType.ChangeLoadState:
return {
...state,
loading: (action as ChangeLoadStateAction).payload
}
}
return state
}
export default AppReducer

View File

@ -1,6 +1,8 @@
import { combineReducers } from 'redux' import { combineReducers } from 'redux'
import ViewReducer from './viewReducer' import ViewReducer from './viewReducer'
import AppReducer from './appReducer'
export default combineReducers({ export default combineReducers({
currentView: ViewReducer currentView: ViewReducer,
app: AppReducer
}) })

View File

@ -1,4 +1,6 @@
import { createStore } from 'redux' import { createStore } from 'redux'
import reducer from './reducers' import reducer from './reducers'
export type StoreType = ReturnType<typeof reducer>
export default createStore(reducer) export default createStore(reducer)

View File

Before

Width:  |  Height:  |  Size: 244 KiB

After

Width:  |  Height:  |  Size: 244 KiB

View File

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 124 KiB

6
typings/redux-augment.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
import { StoreType } from '../src/renderer/redux/store'
declare module 'react-redux' {
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface DefaultRootState extends StoreType {}
}

16
typings/static-import.d.ts vendored Normal file
View File

@ -0,0 +1,16 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
declare module '*.png' {
const value: any
export default value
}
declare module '*.jpg' {
const value: any
export default value
}
declare module '*.gif' {
const value: any
export default value
}