From d9394432d257f872669bfff7ea0ea6102e371948 Mon Sep 17 00:00:00 2001 From: Daniel Scalzi Date: Tue, 26 May 2020 01:50:55 -0400 Subject: [PATCH] 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. --- build.js | 3 +- package-lock.json | 54 ++++++++ package.json | 4 + src/renderer/components/Application.css | 34 +++++ src/renderer/components/Application.tsx | 132 ++++++++++++++++++-- src/renderer/components/loader/Loader.css | 62 +++++++++ src/renderer/components/loader/Loader.tsx | 23 ++++ src/renderer/components/login/Login.css | 1 - src/renderer/components/welcome/Welcome.css | 1 + src/renderer/index.css | 2 + src/renderer/index.tsx | 13 -- src/renderer/redux/actions/appActions.ts | 19 +++ src/renderer/redux/reducers/appReducer.ts | 24 ++++ src/renderer/redux/reducers/index.ts | 4 +- src/renderer/redux/store.ts | 2 + {assets => static}/images/LoadingSeal.png | Bin {assets => static}/images/LoadingText.png | Bin typings/redux-augment.d.ts | 6 + typings/static-import.d.ts | 16 +++ 19 files changed, 372 insertions(+), 28 deletions(-) create mode 100644 src/renderer/components/loader/Loader.css create mode 100644 src/renderer/components/loader/Loader.tsx create mode 100644 src/renderer/redux/actions/appActions.ts create mode 100644 src/renderer/redux/reducers/appReducer.ts rename {assets => static}/images/LoadingSeal.png (100%) rename {assets => static}/images/LoadingText.png (100%) create mode 100644 typings/redux-augment.d.ts create mode 100644 typings/static-import.d.ts diff --git a/build.js b/build.js index e96e733e..81955ee4 100644 --- a/build.js +++ b/build.js @@ -57,7 +57,8 @@ builder.build({ '!{dist,.gitignore,.vscode,docs,dev-app-update.yml,.travis.yml,.nvmrc,.eslintrc.json,build.js}' ], extraResources: [ - 'libraries' + 'libraries', + 'static' ], asar: true } diff --git a/package-lock.json b/package-lock.json index a25cbe22..bd7fbbed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1686,6 +1686,12 @@ "@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": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz", @@ -1740,6 +1746,15 @@ "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": { "version": "2.48.5", "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.5.tgz", @@ -4464,6 +4479,33 @@ "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": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.2.tgz", @@ -10831,6 +10873,18 @@ "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": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/read-config-file/-/read-config-file-6.0.0.tgz", diff --git a/package.json b/package.json index d7affa3e..3773a3e5 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "github-syntax-dark": "^0.5.0", "got": "^11.1.4", "jquery": "^3.5.1", + "lodash": "^4.17.15", "moment": "^2.26.0", "request": "^2.88.2", "semver": "^7.3.2", @@ -53,11 +54,13 @@ "@types/discord-rpc": "^3.0.4", "@types/fs-extra": "^9.0.1", "@types/jquery": "^3.3.38", + "@types/lodash": "^4.14.152", "@types/mocha": "^7.0.2", "@types/node": "^12.12.42", "@types/react": "^16.9.35", "@types/react-dom": "^16.9.8", "@types/react-redux": "^7.1.9", + "@types/react-transition-group": "^4.4.0", "@types/request": "^2.48.5", "@types/tar-fs": "^2.0.0", "@types/triple-beam": "^1.3.1", @@ -79,6 +82,7 @@ "react-dom": "^16.13.0", "react-hot-loader": "^4.12.21", "react-redux": "^7.2.0", + "react-transition-group": "^4.4.1", "redux": "^4.0.5", "rimraf": "^3.0.2", "ts-node": "^8.10.1", diff --git a/src/renderer/components/Application.css b/src/renderer/components/Application.css index 737009e3..d10ca79b 100644 --- a/src/renderer/components/Application.css +++ b/src/renderer/components/Application.css @@ -1,4 +1,38 @@ .appWrapper { height: calc(100vh - 22px); 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; } \ No newline at end of file diff --git a/src/renderer/components/Application.tsx b/src/renderer/components/Application.tsx index 3a43037e..4af53865 100644 --- a/src/renderer/components/Application.tsx +++ b/src/renderer/components/Application.tsx @@ -6,18 +6,61 @@ import { connect } from 'react-redux' import { View } from '../meta/Views' import Landing from './landing/Landing' import Login from './login/Login' +import Loader from './loader/Loader' import Settings from './settings/Settings' 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 } -class Application extends React.Component { +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 { + + private bkid!: number + + constructor(props: ApplicationProps & typeof mapDispatch) { + super(props) + this.state = { + loading: true, + showMain: false, + renderMain: false, + workingView: props.currentView + } + } getViewElement(): JSX.Element { - switch(this.props.currentView) { + switch(this.state.workingView) { case View.WELCOME: return <> @@ -38,21 +81,86 @@ class Application extends React.Component { } } - 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 => { + 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 ( <> -
- {this.getViewElement()} -
+ +
+ + {this.getViewElement()} + + +
+
+ + + ) } } -const connected = connect((state: any) => ({ - currentView: state.currentView -}), undefined)(Application) - -export default hot(connected) \ No newline at end of file +export default hot(connect(mapState, mapDispatch)(Application)) \ No newline at end of file diff --git a/src/renderer/components/loader/Loader.css b/src/renderer/components/loader/Loader.css new file mode 100644 index 00000000..94792274 --- /dev/null +++ b/src/renderer/components/loader/Loader.css @@ -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; +} \ No newline at end of file diff --git a/src/renderer/components/loader/Loader.tsx b/src/renderer/components/loader/Loader.tsx new file mode 100644 index 00000000..e932b2cc --- /dev/null +++ b/src/renderer/components/loader/Loader.tsx @@ -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 <> +
+
+
+ + +
+
+
+ + } + +} \ No newline at end of file diff --git a/src/renderer/components/login/Login.css b/src/renderer/components/login/Login.css index bbe80ef7..7e2e062b 100644 --- a/src/renderer/components/login/Login.css +++ b/src/renderer/components/login/Login.css @@ -19,7 +19,6 @@ align-items: center; height: 100%; width: 100%; - transition: filter 0.25s ease; background: rgba(0, 0, 0, 0.50); } diff --git a/src/renderer/components/welcome/Welcome.css b/src/renderer/components/welcome/Welcome.css index a30b20ad..5276ba24 100644 --- a/src/renderer/components/welcome/Welcome.css +++ b/src/renderer/components/welcome/Welcome.css @@ -5,6 +5,7 @@ align-items: center; height: 100%; width: 100%; + background: rgba(0, 0, 0, 0.50); } #welcomeContent { diff --git a/src/renderer/index.css b/src/renderer/index.css index 7a394e81..81d23bbb 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -28,6 +28,8 @@ /* TODO: Temp for development */ body { /* 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; } diff --git a/src/renderer/index.tsx b/src/renderer/index.tsx index 26813709..d5e1528b 100644 --- a/src/renderer/index.tsx +++ b/src/renderer/index.tsx @@ -6,19 +6,6 @@ import './index.css' import Application from './components/Application' 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 const mainElement = document.createElement('div') diff --git a/src/renderer/redux/actions/appActions.ts b/src/renderer/redux/actions/appActions.ts new file mode 100644 index 00000000..d83de66f --- /dev/null +++ b/src/renderer/redux/actions/appActions.ts @@ -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 + } +} \ No newline at end of file diff --git a/src/renderer/redux/reducers/appReducer.ts b/src/renderer/redux/reducers/appReducer.ts new file mode 100644 index 00000000..c08e3cd7 --- /dev/null +++ b/src/renderer/redux/reducers/appReducer.ts @@ -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 = (state = defaultAppState, action) => { + switch(action.type) { + case AppActionType.ChangeLoadState: + return { + ...state, + loading: (action as ChangeLoadStateAction).payload + } + } + return state +} + +export default AppReducer \ No newline at end of file diff --git a/src/renderer/redux/reducers/index.ts b/src/renderer/redux/reducers/index.ts index 197b150c..d5cc9281 100644 --- a/src/renderer/redux/reducers/index.ts +++ b/src/renderer/redux/reducers/index.ts @@ -1,6 +1,8 @@ import { combineReducers } from 'redux' import ViewReducer from './viewReducer' +import AppReducer from './appReducer' export default combineReducers({ - currentView: ViewReducer + currentView: ViewReducer, + app: AppReducer }) \ No newline at end of file diff --git a/src/renderer/redux/store.ts b/src/renderer/redux/store.ts index 24106446..8d11c98e 100644 --- a/src/renderer/redux/store.ts +++ b/src/renderer/redux/store.ts @@ -1,4 +1,6 @@ import { createStore } from 'redux' import reducer from './reducers' +export type StoreType = ReturnType + export default createStore(reducer) \ No newline at end of file diff --git a/assets/images/LoadingSeal.png b/static/images/LoadingSeal.png similarity index 100% rename from assets/images/LoadingSeal.png rename to static/images/LoadingSeal.png diff --git a/assets/images/LoadingText.png b/static/images/LoadingText.png similarity index 100% rename from assets/images/LoadingText.png rename to static/images/LoadingText.png diff --git a/typings/redux-augment.d.ts b/typings/redux-augment.d.ts new file mode 100644 index 00000000..07551af5 --- /dev/null +++ b/typings/redux-augment.d.ts @@ -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 {} +} diff --git a/typings/static-import.d.ts b/typings/static-import.d.ts new file mode 100644 index 00000000..1b3e98bd --- /dev/null +++ b/typings/static-import.d.ts @@ -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 +} \ No newline at end of file