Bladeren bron

feat: help dialog

    * fix(performance): don't rerender every artist when scrolling
    * refactor: component directory structure (removed unnecessary index.ts(x)?)
    * chore: license and precommit hook stuff
    * feat: help overlay
    * fix: encode uri component when fetching songs
    * fix: limit width of artists and songs containers
Fela Maslen 5 jaren geleden
bovenliggende
commit
9d653d3f1c
30 gewijzigde bestanden met toevoegingen van 444 en 65 verwijderingen
  1. 28 1
      gmus/package.json
  2. 10 10
      gmus/src/components/app.tsx
  3. 2 1
      gmus/src/components/identify.spec.tsx
  4. 0 0
      gmus/src/components/identify.tsx
  5. 0 0
      gmus/src/components/player.tsx
  6. 0 0
      gmus/src/components/player/index.spec.tsx
  7. 6 6
      gmus/src/components/root.tsx
  8. 1 0
      gmus/src/components/ui/cmus/index.ts
  9. 31 1
      gmus/src/components/ui/cmus/reducer.spec.ts
  10. 8 1
      gmus/src/components/ui/cmus/reducer.ts
  11. 15 0
      gmus/src/components/ui/cmus/styled/layout.ts
  12. 6 1
      gmus/src/components/ui/cmus/types.ts
  13. 3 3
      gmus/src/components/ui/cmus/views/artists.styles.ts
  14. 20 23
      gmus/src/components/ui/cmus/views/artists.tsx
  15. 0 0
      gmus/src/components/ui/cmus/views/command.styles.ts
  16. 1 1
      gmus/src/components/ui/cmus/views/command.tsx
  17. 32 0
      gmus/src/components/ui/cmus/views/help.styles.ts
  18. 51 0
      gmus/src/components/ui/cmus/views/help.tsx
  19. 3 2
      gmus/src/components/ui/cmus/views/library.tsx
  20. 3 3
      gmus/src/components/ui/cmus/views/songs.styles.tsx
  21. 1 1
      gmus/src/components/ui/cmus/views/songs.tsx
  22. 1 1
      gmus/src/components/ui/cmus/views/status.spec.tsx
  23. 0 0
      gmus/src/components/ui/cmus/views/status.styles.ts
  24. 1 1
      gmus/src/components/ui/cmus/views/status.tsx
  25. 11 0
      gmus/src/components/ui/cmus/wrapper.styles.ts
  26. 12 4
      gmus/src/components/ui/cmus/wrapper.tsx
  27. 1 1
      gmus/src/hooks/fetch/artists.ts
  28. 1 0
      gmus/src/hooks/vim.ts
  29. 10 0
      gmus/src/utils/component.tsx
  30. 186 4
      gmus/yarn.lock

+ 28 - 1
gmus/package.json

@@ -1,7 +1,7 @@
 {
   "name": "gmus",
   "version": "0.1.0",
-  "private": true,
+  "private": false,
   "scripts": {
     "start": "react-scripts start",
     "build": "react-scripts build",
@@ -9,6 +9,31 @@
     "test": "react-scripts test",
     "eject": "react-scripts eject"
   },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/felamaslen/go-music-player.git"
+  },
+  "author": "Fela Maslen",
+  "license": "MIT",
+  "bugs": {
+    "url": "https://github.com/felamaslen/go-music-player/issues"
+  },
+  "husky": {
+    "hooks": {
+      "pre-commit": "lint-staged"
+    }
+  },
+  "lint-staged": {
+    "*.js": [
+      "eslint --fix"
+    ],
+    "*.ts": [
+      "eslint --fix"
+    ],
+    "*.tsx": [
+      "eslint --fix"
+    ]
+  },
   "eslintConfig": {
     "extends": [
       "react-app",
@@ -44,7 +69,9 @@
     "eslint-config-airbnb-base": "^14.2.1",
     "eslint-config-prettier": "^7.0.0",
     "eslint-plugin-prettier": "^3.3.0",
+    "husky": "^4.3.6",
     "jest-websocket-mock": "^2.2.0",
+    "lint-staged": "^10.5.3",
     "mock-socket": "^9.0.3",
     "nanoid": "^3.1.20",
     "nock": "^13.0.5",

+ 10 - 10
gmus/src/components/app/index.tsx → gmus/src/components/app.tsx

@@ -1,15 +1,15 @@
 import React, { Dispatch, useCallback } from 'react';
 
-import { LocalAction, stateSet } from '../../actions';
-import { DispatchContext, StateContext } from '../../context/state';
-import { useMaster } from '../../hooks/master';
-import { useKeepalive } from '../../hooks/socket';
-import { GlobalState } from '../../reducer/types';
-import { isMaster } from '../../selectors';
-import { getSongUrl } from '../../utils/url';
-import { Player } from '../player';
-import { uiProviders } from '../ui';
-import { UIProvider } from '../ui/types';
+import { LocalAction, stateSet } from '../actions';
+import { DispatchContext, StateContext } from '../context/state';
+import { useMaster } from '../hooks/master';
+import { useKeepalive } from '../hooks/socket';
+import { GlobalState } from '../reducer';
+import { isMaster } from '../selectors';
+import { getSongUrl } from '../utils/url';
+import { Player } from './player';
+import { uiProviders } from './ui';
+import { UIProvider } from './ui/types';
 
 export type Props = {
   socket: WebSocket;

+ 2 - 1
gmus/src/components/identify/index.spec.tsx → gmus/src/components/identify.spec.tsx

@@ -1,6 +1,7 @@
 import { act, fireEvent, render } from '@testing-library/react';
 import React from 'react';
-import { Identify, Props } from './index';
+
+import { Identify, Props } from './identify';
 
 describe(Identify.name, () => {
   const props: Props = {

+ 0 - 0
gmus/src/components/identify/index.tsx → gmus/src/components/identify.tsx


+ 0 - 0
gmus/src/components/player/index.tsx → gmus/src/components/player.tsx


+ 0 - 0
gmus/src/components/player/index.spec.tsx


+ 6 - 6
gmus/src/components/root/index.tsx → gmus/src/components/root.tsx

@@ -1,11 +1,11 @@
 import React, { Reducer, useCallback, useReducer } from 'react';
-import { AnyAction, nameSet } from '../../actions';
 
-import { useDispatchWithEffects, useOnMessage, useSocket } from '../../hooks/socket';
-import { globalReducer, GlobalState, initialState } from '../../reducer';
-import { init } from '../../utils/state';
-import { App } from '../app';
-import { Identify } from '../identify';
+import { AnyAction, nameSet } from '../actions';
+import { useDispatchWithEffects, useOnMessage, useSocket } from '../hooks/socket';
+import { globalReducer, GlobalState, initialState } from '../reducer';
+import { init } from '../utils/state';
+import { App } from './app';
+import { Identify } from './identify';
 
 export const Root: React.FC = () => {
   const [state, dispatch] = useReducer<Reducer<GlobalState, AnyAction>, GlobalState>(

+ 1 - 0
gmus/src/components/ui/cmus/index.ts

@@ -0,0 +1 @@
+export * from './wrapper';

+ 31 - 1
gmus/src/components/ui/cmus/reducer.spec.ts

@@ -9,7 +9,7 @@ import {
   commandSet,
 } from './actions';
 import { cmusUIReducer, initialCmusUIState } from './reducer';
-import { CmusUIState, LibraryModeWindow, View } from './types';
+import { CmusUIState, LibraryModeWindow, Overlay, View } from './types';
 
 describe(cmusUIReducer.name, () => {
   const stateLibrary: CmusUIState = {
@@ -392,6 +392,26 @@ describe(cmusUIReducer.name, () => {
       });
     });
 
+    describe(Keys.esc, () => {
+      const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.esc };
+
+      describe.each`
+        overlay
+        ${Overlay.Help}
+      `('when the overlay is set to $overlay', ({ overlay }) => {
+        const stateWithOverlay: CmusUIState = {
+          ...initialCmusUIState,
+          overlay,
+        };
+
+        it('should reset the overlay', () => {
+          expect.assertions(1);
+          const result = cmusUIReducer(stateWithOverlay, action);
+          expect(result.overlay).toBeNull();
+        });
+      });
+    });
+
     describe(Keys.colon, () => {
       const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.colon };
 
@@ -401,5 +421,15 @@ describe(cmusUIReducer.name, () => {
         expect(result.commandMode).toBe(true);
       });
     });
+
+    describe(Keys.question, () => {
+      const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.question };
+
+      it('should set the overlay to help mode', () => {
+        expect.assertions(1);
+        const result = cmusUIReducer(initialCmusUIState, action);
+        expect(result.overlay).toBe(Overlay.Help);
+      });
+    });
   });
 });

+ 8 - 1
gmus/src/components/ui/cmus/reducer.ts

@@ -11,13 +11,14 @@ import {
   CmusUIAction,
   CmusUIActionType,
 } from './actions';
-import { CmusUIState, LibraryModeWindow, View } from './types';
+import { CmusUIState, LibraryModeWindow, Overlay, View } from './types';
 
 export const initialCmusUIState: CmusUIState = {
   globalAction: null,
   globalActionSerialNumber: 0,
   view: View.Library,
   commandMode: false,
+  overlay: null,
   artists: [],
   artistAlbums: {},
   artistSongs: {},
@@ -174,6 +175,12 @@ function handleKeyPress(state: CmusUIState, key: string): CmusUIState {
 
       return state;
 
+    case Keys.esc:
+      return { ...state, overlay: null };
+
+    case Keys.question:
+      return { ...state, overlay: Overlay.Help };
+
     case Keys.C:
       return withGlobalAction(state, playPaused());
 

+ 15 - 0
gmus/src/components/ui/cmus/styled/layout.ts

@@ -11,6 +11,10 @@ export const FlexColumn = styled(FlexRow)`
   flex-flow: column;
 `;
 
+export const NoWrap = styled.div`
+  white-space: nowrap;
+`;
+
 export const ActiveHighlightRow = styled(FlexRow)<{
   active?: boolean;
   highlight?: boolean;
@@ -39,5 +43,16 @@ export const ActiveHighlightRow = styled(FlexRow)<{
   font-weight: ${({ active, highlight }): CSSProperties['fontWeight'] =>
     active || highlight ? 'bold' : 'normal'};
 
+  white-space: nowrap;
   width: 100%;
+
+  span {
+    overflow: hidden;
+    text-overflow: ellipsis;
+    width: 100%;
+  }
+`;
+
+export const FlexList = styled(FlexColumn)`
+  min-width: 0;
 `;

+ 6 - 1
gmus/src/components/ui/cmus/types.ts

@@ -2,7 +2,11 @@ import { LocalAction } from '../../../actions';
 import { Song } from '../../../types/songs';
 
 export enum View {
-  Library,
+  Library = 'Library',
+}
+
+export enum Overlay {
+  Help = 'Help',
 }
 
 export enum LibraryModeWindow {
@@ -15,6 +19,7 @@ export type CmusUIState = {
   globalActionSerialNumber: number;
   view: View;
   commandMode: boolean;
+  overlay: Overlay | null;
   artists: string[];
   artistAlbums: Record<string, string[]>;
   artistSongs: Record<string, Song[]>;

+ 3 - 3
gmus/src/components/ui/cmus/artists/styles.ts → gmus/src/components/ui/cmus/views/artists.styles.ts

@@ -1,10 +1,10 @@
 import { rem } from 'polished';
 import styled from 'styled-components';
 
-import { ActiveHighlightRow, FlexColumn } from '../styled/layout';
+import { ActiveHighlightRow, FlexColumn, FlexList } from '../styled/layout';
 
-export const Container = styled(FlexColumn)`
-  flex: 1;
+export const Container = styled(FlexList)`
+  flex: 1 0 0;
 `;
 
 export const ArtistRow = styled(FlexColumn)``;

+ 20 - 23
gmus/src/components/ui/cmus/artists/index.tsx → gmus/src/components/ui/cmus/views/artists.tsx

@@ -3,10 +3,11 @@ import React, { useContext, useEffect } from 'react';
 
 import { useArtistsAlbumsAndSongs } from '../../../../hooks/fetch/artists';
 import { Song } from '../../../../types/songs';
+import { namedMemo } from '../../../../utils/component';
 import { artistAlbumsLoaded, artistSongsLoaded } from '../actions';
 import { CmusUIDispatchContext, CmusUIStateContext } from '../reducer';
 
-import * as Styled from './styles';
+import * as Styled from './artists.styles';
 
 type Props = {
   active: boolean;
@@ -22,28 +23,24 @@ type PropsArtist = {
   activeAlbum: string | null;
 };
 
-const Artist: React.FC<PropsArtist> = ({
-  artist,
-  albums,
-  active,
-  parentActive,
-  expanded,
-  activeAlbum,
-}) => (
-  <Styled.ArtistRow key={artist}>
-    <Styled.ArtistTitle active={active} parentActive={parentActive}>
-      {artist || 'Unknown Artist'}
-    </Styled.ArtistTitle>
-    {expanded && (
-      <Styled.ArtistAlbums>
-        {albums?.map((album) => (
-          <Styled.AlbumTitle key={album} active={active && album === activeAlbum}>
-            {album || 'Unknown Album'}
-          </Styled.AlbumTitle>
-        ))}
-      </Styled.ArtistAlbums>
-    )}
-  </Styled.ArtistRow>
+const Artist = namedMemo<PropsArtist>(
+  'Artist',
+  ({ artist, albums, active, parentActive, expanded, activeAlbum }) => (
+    <Styled.ArtistRow key={artist}>
+      <Styled.ArtistTitle active={active} parentActive={parentActive}>
+        <span>{artist || 'Unknown Artist'}</span>
+      </Styled.ArtistTitle>
+      {expanded && (
+        <Styled.ArtistAlbums>
+          {albums?.map((album) => (
+            <Styled.AlbumTitle key={album} active={active && album === activeAlbum}>
+              {album || 'Unknown Album'}
+            </Styled.AlbumTitle>
+          ))}
+        </Styled.ArtistAlbums>
+      )}
+    </Styled.ArtistRow>
+  ),
 );
 
 export const Artists: React.FC<Props> = ({ active }) => {

+ 0 - 0
gmus/src/components/ui/cmus/command/styles.ts → gmus/src/components/ui/cmus/views/command.styles.ts


+ 1 - 1
gmus/src/components/ui/cmus/command/index.tsx → gmus/src/components/ui/cmus/views/command.tsx

@@ -5,7 +5,7 @@ import { Keys } from '../../../../hooks/vim';
 import { commandSet } from '../actions';
 import { CmusUIDispatchContext, CmusUIStateContext } from '../reducer';
 
-import * as Styled from './styles';
+import * as Styled from './command.styles';
 
 enum EndState {
   Start,

+ 32 - 0
gmus/src/components/ui/cmus/views/help.styles.ts

@@ -0,0 +1,32 @@
+import { rem } from 'polished';
+import styled from 'styled-components';
+
+import { FlexColumn } from '../styled/layout';
+import { colors } from '../styled/variables';
+
+export const HelpDialogContainer = styled(FlexColumn)`
+  background: ${colors.background};
+  border: 1px solid ${colors.active.color};
+  padding: ${rem(8)} ${rem(16)};
+
+  h3 {
+    font-weight: bold;
+  }
+`;
+
+export const Commands = styled(FlexColumn)`
+  flex: 1;
+  margin-right: ${rem(24)};
+`;
+
+export const Descriptions = styled(FlexColumn)`
+  flex: 2;
+  text-align: left;
+  white-space: nowrap;
+`;
+
+export const CommandGroupTitle = styled.h5`
+  color: ${colors.selected.inactive};
+  font-style: italic;
+  margin: ${rem(8)} 0;
+`;

+ 51 - 0
gmus/src/components/ui/cmus/views/help.tsx

@@ -0,0 +1,51 @@
+import React from 'react';
+import { FlexColumn, FlexRow, NoWrap } from '../styled/layout';
+
+import * as Styled from './help.styles';
+
+type Command = { command: string; description: string };
+
+const commandsGeneral: Command[] = [
+  { command: 'c', description: 'play / pause' },
+  { command: 'j', description: 'select next list item' },
+  { command: 'k', description: 'select previous list item' },
+  { command: ':q', description: 'log out' },
+  { command: '<Esc>', description: 'close this dialog' },
+];
+
+const commandsLibrary: Command[] = [
+  { command: '<Tab>', description: 'switch between artists and albums' },
+];
+
+type CommandGroup = {
+  title: string;
+  commands: Command[];
+};
+
+const commandGroups: CommandGroup[] = [
+  { title: 'Library view', commands: commandsLibrary },
+  { title: 'General', commands: commandsGeneral },
+];
+
+export const HelpDialog: React.FC = () => (
+  <Styled.HelpDialogContainer>
+    <h3>Commands available</h3>
+    {commandGroups.map(({ title, commands }) => (
+      <FlexColumn key={title}>
+        <Styled.CommandGroupTitle>{title}</Styled.CommandGroupTitle>
+        <FlexRow>
+          <Styled.Commands>
+            {commands.map(({ command }) => (
+              <NoWrap key={command}>{command}</NoWrap>
+            ))}
+          </Styled.Commands>
+          <Styled.Commands>
+            {commands.map(({ command, description }) => (
+              <NoWrap key={command}>{description}</NoWrap>
+            ))}
+          </Styled.Commands>
+        </FlexRow>
+      </FlexColumn>
+    ))}
+  </Styled.HelpDialogContainer>
+);

+ 3 - 2
gmus/src/components/ui/cmus/views/library.tsx

@@ -3,11 +3,12 @@ import React, { useContext, useEffect } from 'react';
 import { useArtists } from '../../../../hooks/fetch/artists';
 import { artistsSet } from '../actions';
 
-import { Artists } from '../artists';
 import { CmusUIDispatchContext, CmusUIStateContext } from '../reducer';
-import { Songs } from '../songs';
 import { LibraryModeWindow } from '../types';
 
+import { Artists } from './artists';
+import { Songs } from './songs';
+
 export const ViewLibrary: React.FC = () => {
   const dispatchUI = useContext(CmusUIDispatchContext);
   const { library } = useContext(CmusUIStateContext);

+ 3 - 3
gmus/src/components/ui/cmus/songs/styles.ts → gmus/src/components/ui/cmus/views/songs.styles.tsx

@@ -1,11 +1,11 @@
 import styled from 'styled-components';
 
-import { ActiveHighlightRow, FlexRow } from '../styled/layout';
+import { ActiveHighlightRow, FlexList } from '../styled/layout';
 import { colors } from '../styled/variables';
 
-export const Container = styled(FlexRow)`
+export const Container = styled(FlexList)`
   border-left: 1px solid ${colors.border};
-  flex: 2;
+  flex: 2 0 0;
   flex-flow: column;
   height: 100%;
 `;

+ 1 - 1
gmus/src/components/ui/cmus/songs/index.tsx → gmus/src/components/ui/cmus/views/songs.tsx

@@ -3,7 +3,7 @@ import React, { useContext, useMemo } from 'react';
 import { Song } from '../../../../types';
 import { CmusUIStateContext } from '../reducer';
 
-import * as Styled from './styles';
+import * as Styled from './songs.styles';
 
 type Props = {
   active: boolean;

+ 1 - 1
gmus/src/components/ui/cmus/status/index.spec.tsx → gmus/src/components/ui/cmus/views/status.spec.tsx

@@ -5,7 +5,7 @@ import React from 'react';
 import { StateContext } from '../../../../context/state';
 import { GlobalState, initialState, nullPlayer } from '../../../../reducer';
 
-import { PlayerStatus } from '.';
+import { PlayerStatus } from './status';
 
 describe(PlayerStatus.name, () => {
   const nockSongInfo = (id: number): nock.Scope =>

+ 0 - 0
gmus/src/components/ui/cmus/status/styles.ts → gmus/src/components/ui/cmus/views/status.styles.ts


+ 1 - 1
gmus/src/components/ui/cmus/status/index.tsx → gmus/src/components/ui/cmus/views/status.tsx

@@ -8,7 +8,7 @@ import { MusicPlayer, Song } from '../../../../types';
 import { formatTime } from '../../../../utils/time';
 import { getApiUrl } from '../../../../utils/url';
 
-import * as Styled from './styles';
+import * as Styled from './status.styles';
 
 type SongInfoQuery = { id: number };
 

+ 11 - 0
gmus/src/components/ui/cmus/styles.ts → gmus/src/components/ui/cmus/wrapper.styles.ts

@@ -20,4 +20,15 @@ export const Wrapper = styled(FlexColumn)`
 
 export const View = styled(FlexRow)`
   flex: 1;
+  overflow: hidden;
+  width: 100%;
+  z-index: 10;
+`;
+
+export const Overlay = styled.div`
+  left: 50%;
+  position: absolute;
+  top: 50%;
+  transform: translateX(-50%) translateY(-50%);
+  z-index: 20;
 `;

+ 12 - 4
gmus/src/components/ui/cmus/index.tsx → gmus/src/components/ui/cmus/wrapper.tsx

@@ -5,18 +5,21 @@ import { useVimBindings } from '../../../hooks/vim';
 
 import { UIProviderComponent } from '../types';
 import { CmusUIAction } from './actions';
-import { CommandView } from './command';
+
 import {
   CmusUIDispatchContext,
   cmusUIReducer,
   CmusUIStateContext,
   initialCmusUIState,
 } from './reducer';
-import { PlayerStatus } from './status';
+import { CmusUIState, Overlay, View } from './types';
 
-import * as Styled from './styles';
-import { CmusUIState, View } from './types';
+import { CommandView } from './views/command';
+import { HelpDialog } from './views/help';
 import { ViewLibrary } from './views/library';
+import { PlayerStatus } from './views/status';
+
+import * as Styled from './wrapper.styles';
 
 export const CmusUIProvider: UIProviderComponent = () => {
   const dispatch = useContext(DispatchContext);
@@ -38,6 +41,11 @@ export const CmusUIProvider: UIProviderComponent = () => {
       <CmusUIDispatchContext.Provider value={dispatchUI}>
         <Styled.Wrapper>
           <Styled.View>{stateUI.view === View.Library && <ViewLibrary />}</Styled.View>
+          {!!stateUI.overlay && (
+            <>
+              <Styled.Overlay>{stateUI.overlay === Overlay.Help && <HelpDialog />}</Styled.Overlay>
+            </>
+          )}
           <PlayerStatus />
           <CommandView />
         </Styled.Wrapper>

+ 1 - 1
gmus/src/hooks/fetch/artists.ts

@@ -47,7 +47,7 @@ const sendSongsRequest = (
   axios: AxiosInstance,
   query: SongsQuery,
 ): Promise<AxiosResponse<SongsResponse>> =>
-  axios.get(`${getApiUrl()}/songs?artist=${query.artist}`);
+  axios.get(`${getApiUrl()}/songs?artist=${encodeURIComponent(query.artist)}`);
 
 export function useArtists(): ArtistsResponse & {
   fetching: boolean;

+ 1 - 0
gmus/src/hooks/vim.ts

@@ -6,6 +6,7 @@ export const Keys = {
   esc: 'Escape',
   space: ' ',
   colon: ':',
+  question: '?',
   '1': '1',
   C: 'c',
   J: 'j',

+ 10 - 0
gmus/src/utils/component.tsx

@@ -0,0 +1,10 @@
+import React from 'react';
+
+export function namedMemo<P extends Record<string, unknown>>(
+  componentName: string,
+  Component: React.FC<P>,
+): React.FC<P> {
+  const NamedComponent = Component;
+  NamedComponent.displayName = componentName;
+  return React.memo<P>(NamedComponent);
+}

+ 186 - 4
gmus/yarn.lock

@@ -2413,7 +2413,7 @@ ansi-colors@^4.1.1:
   resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348"
   integrity sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==
 
-ansi-escapes@^4.2.1, ansi-escapes@^4.3.1:
+ansi-escapes@^4.2.1, ansi-escapes@^4.3.0, ansi-escapes@^4.3.1:
   version "4.3.1"
   resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.1.tgz#a5c47cc43181f1f38ffd7076837700d395522a61"
   integrity sha512-JWF7ocqNrp8u9oqpgV+wH5ftbt+cfvv+PTjOvKLT3AdYly/LmORARfEVT1iyjwN+4MqE5UmVKoAdIBqeoCHgLA==
@@ -2630,6 +2630,11 @@ astral-regex@^1.0.0:
   resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-1.0.0.tgz#6c8c3fb827dd43ee3918f27b82782ab7658a6fd9"
   integrity sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg==
 
+astral-regex@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31"
+  integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==
+
 async-each@^1.0.1:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.3.tgz#b727dbf87d7651602f06f4d4ac387f47d91b0cbf"
@@ -3458,6 +3463,21 @@ clean-stack@^2.0.0:
   resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b"
   integrity sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==
 
+cli-cursor@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-3.1.0.tgz#264305a7ae490d1d03bf0c9ba7c925d1753af307"
+  integrity sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==
+  dependencies:
+    restore-cursor "^3.1.0"
+
+cli-truncate@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/cli-truncate/-/cli-truncate-2.1.0.tgz#c39e28bf05edcde5be3b98992a22deed5a2b93c7"
+  integrity sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==
+  dependencies:
+    slice-ansi "^3.0.0"
+    string-width "^4.2.0"
+
 cliui@^5.0.0:
   version "5.0.0"
   resolved "https://registry.yarnpkg.com/cliui/-/cliui-5.0.0.tgz#deefcfdb2e800784aa34f46fa08e06851c7bbbc5"
@@ -3574,6 +3594,11 @@ commander@^4.1.1:
   resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068"
   integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==
 
+commander@^6.2.0:
+  version "6.2.1"
+  resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c"
+  integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==
+
 common-tags@^1.8.0:
   version "1.8.0"
   resolved "https://registry.yarnpkg.com/common-tags/-/common-tags-1.8.0.tgz#8e3153e542d4a39e9b10554434afaaf98956a937"
@@ -3584,6 +3609,11 @@ commondir@^1.0.1:
   resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b"
   integrity sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=
 
+compare-versions@^3.6.0:
+  version "3.6.0"
+  resolved "https://registry.yarnpkg.com/compare-versions/-/compare-versions-3.6.0.tgz#1a5689913685e5a87637b8d3ffca75514ec41d62"
+  integrity sha512-W6Af2Iw1z4CB7q4uU4hv646dW9GQuBM+YpC0UvUCWSD8w90SJjp+ujJuXaEMtAXBtSqGfMPuFOVn4/+FlaqfBA==
+
 component-emitter@^1.2.1:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0"
@@ -4147,7 +4177,7 @@ debug@^3.1.1, debug@^3.2.5:
   dependencies:
     ms "^2.1.1"
 
-debug@^4.0.1, debug@^4.1.0, debug@^4.1.1:
+debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.2.0:
   version "4.3.1"
   resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.1.tgz#f0d229c505e0c6d8c49ac553d1b13dc183f6b2ee"
   integrity sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==
@@ -4542,7 +4572,7 @@ enhanced-resolve@^4.3.0:
     memory-fs "^0.5.0"
     tapable "^1.0.0"
 
-enquirer@^2.3.5:
+enquirer@^2.3.5, enquirer@^2.3.6:
   version "2.3.6"
   resolved "https://registry.yarnpkg.com/enquirer/-/enquirer-2.3.6.tgz#2a7fe5dd634a1e4125a975ec994ff5456dc3734d"
   integrity sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==
@@ -4994,7 +5024,7 @@ execa@^1.0.0:
     signal-exit "^3.0.0"
     strip-eof "^1.0.0"
 
-execa@^4.0.0:
+execa@^4.0.0, execa@^4.1.0:
   version "4.1.0"
   resolved "https://registry.yarnpkg.com/execa/-/execa-4.1.0.tgz#4e5491ad1572f2f17a77d388c6c857135b22847a"
   integrity sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==
@@ -5191,6 +5221,13 @@ figgy-pudding@^3.5.1:
   resolved "https://registry.yarnpkg.com/figgy-pudding/-/figgy-pudding-3.5.2.tgz#b4eee8148abb01dcf1d1ac34367d59e12fa61d6e"
   integrity sha512-0btnI/H8f2pavGMN8w40mlSKOfTK2SVJmBfBeVIj3kNw0swwgzyRq0d5TJVOwodFmtvpPeWPN/MCcfuWF0Ezbw==
 
+figures@^3.2.0:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/figures/-/figures-3.2.0.tgz#625c18bd293c604dc4a8ddb2febf0c88341746af"
+  integrity sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==
+  dependencies:
+    escape-string-regexp "^1.0.5"
+
 file-entry-cache@^5.0.1:
   version "5.0.1"
   resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-5.0.1.tgz#ca0f6efa6dd3d561333fb14515065c2fafdf439c"
@@ -5291,6 +5328,13 @@ find-up@^3.0.0:
   dependencies:
     locate-path "^3.0.0"
 
+find-versions@^3.2.0:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/find-versions/-/find-versions-3.2.0.tgz#10297f98030a786829681690545ef659ed1d254e"
+  integrity sha512-P8WRou2S+oe222TOCHitLy8zj+SIsVJh52VP4lvXkaFVnOFFdoWv1H1Jjvel1aI6NCFOAaeAVm8qrI0odiLcww==
+  dependencies:
+    semver-regex "^2.0.0"
+
 flat-cache@^2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-2.0.1.tgz#5d296d6f04bda44a4630a301413bdbc2ec085ec0"
@@ -5905,6 +5949,22 @@ human-signals@^1.1.1:
   resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-1.1.1.tgz#c5b1cd14f50aeae09ab6c59fe63ba3395fe4dfa3"
   integrity sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==
 
+husky@^4.3.6:
+  version "4.3.6"
+  resolved "https://registry.yarnpkg.com/husky/-/husky-4.3.6.tgz#ebd9dd8b9324aa851f1587318db4cccb7665a13c"
+  integrity sha512-o6UjVI8xtlWRL5395iWq9LKDyp/9TE7XMOTvIpEVzW638UcGxTmV5cfel6fsk/jbZSTlvfGVJf2svFtybcIZag==
+  dependencies:
+    chalk "^4.0.0"
+    ci-info "^2.0.0"
+    compare-versions "^3.6.0"
+    cosmiconfig "^7.0.0"
+    find-versions "^3.2.0"
+    opencollective-postinstall "^2.0.2"
+    pkg-dir "^4.2.0"
+    please-upgrade-node "^3.2.0"
+    slash "^3.0.0"
+    which-pm-runs "^1.0.0"
+
 iconv-lite@0.4.24:
   version "0.4.24"
   resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
@@ -7131,6 +7191,41 @@ lines-and-columns@^1.1.6:
   resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00"
   integrity sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=
 
+lint-staged@^10.5.3:
+  version "10.5.3"
+  resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-10.5.3.tgz#c682838b3eadd4c864d1022da05daa0912fb1da5"
+  integrity sha512-TanwFfuqUBLufxCc3RUtFEkFraSPNR3WzWcGF39R3f2J7S9+iF9W0KTVLfSy09lYGmZS5NDCxjNvhGMSJyFCWg==
+  dependencies:
+    chalk "^4.1.0"
+    cli-truncate "^2.1.0"
+    commander "^6.2.0"
+    cosmiconfig "^7.0.0"
+    debug "^4.2.0"
+    dedent "^0.7.0"
+    enquirer "^2.3.6"
+    execa "^4.1.0"
+    listr2 "^3.2.2"
+    log-symbols "^4.0.0"
+    micromatch "^4.0.2"
+    normalize-path "^3.0.0"
+    please-upgrade-node "^3.2.0"
+    string-argv "0.3.1"
+    stringify-object "^3.3.0"
+
+listr2@^3.2.2:
+  version "3.2.3"
+  resolved "https://registry.yarnpkg.com/listr2/-/listr2-3.2.3.tgz#ef9e0d790862f038dde8a9837be552b1adfd1c07"
+  integrity sha512-vUb80S2dSUi8YxXahO8/I/s29GqnOL8ozgHVLjfWQXa03BNEeS1TpBLjh2ruaqq5ufx46BRGvfymdBSuoXET5w==
+  dependencies:
+    chalk "^4.1.0"
+    cli-truncate "^2.1.0"
+    figures "^3.2.0"
+    indent-string "^4.0.0"
+    log-update "^4.0.0"
+    p-map "^4.0.0"
+    rxjs "^6.6.3"
+    through "^2.3.8"
+
 load-json-file@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-2.0.0.tgz#7947e42149af80d696cbf797bcaabcfe1fe29ca8"
@@ -7241,6 +7336,23 @@ lodash.uniq@^4.5.0:
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52"
   integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==
 
+log-symbols@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.0.0.tgz#69b3cc46d20f448eccdb75ea1fa733d9e821c920"
+  integrity sha512-FN8JBzLx6CzeMrB0tg6pqlGU1wCrXW+ZXGH481kfsBqer0hToTIiHdjH4Mq8xJUbvATujKCvaREGWpGUionraA==
+  dependencies:
+    chalk "^4.0.0"
+
+log-update@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/log-update/-/log-update-4.0.0.tgz#589ecd352471f2a1c0c570287543a64dfd20e0a1"
+  integrity sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==
+  dependencies:
+    ansi-escapes "^4.3.0"
+    cli-cursor "^3.1.0"
+    slice-ansi "^4.0.0"
+    wrap-ansi "^6.2.0"
+
 loglevel@^1.6.8:
   version "1.7.0"
   resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.7.0.tgz#728166855a740d59d38db01cf46f042caa041bb0"
@@ -7961,6 +8073,11 @@ open@^7.0.2:
     is-docker "^2.0.0"
     is-wsl "^2.1.1"
 
+opencollective-postinstall@^2.0.2:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz#7a0fff978f6dbfa4d006238fbac98ed4198c3259"
+  integrity sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==
+
 opn@^5.5.0:
   version "5.5.0"
   resolved "https://registry.yarnpkg.com/opn/-/opn-5.5.0.tgz#fc7164fab56d235904c51c3b27da6758ca3b9bfc"
@@ -8321,6 +8438,13 @@ pkg-up@3.1.0:
   dependencies:
     find-up "^3.0.0"
 
+please-upgrade-node@^3.2.0:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz#aeddd3f994c933e4ad98b99d9a556efa0e2fe942"
+  integrity sha512-gQR3WpIgNIKwBMVLkpMUeR3e1/E1y42bqDQZfql+kDeXd8COYfM8PQA4X6y7a8u9Ua9FHmsrrmirW2vHs45hWg==
+  dependencies:
+    semver-compare "^1.0.0"
+
 pluralize@^8.0.0:
   version "8.0.0"
   resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-8.0.0.tgz#1a6fa16a38d12a1901e0320fa017051c539ce3b1"
@@ -9749,6 +9873,14 @@ resolve@^1.10.0, resolve@^1.12.0, resolve@^1.13.1, resolve@^1.14.2, resolve@^1.1
     is-core-module "^2.1.0"
     path-parse "^1.0.6"
 
+restore-cursor@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-3.1.0.tgz#39f67c54b3a7a58cea5236d95cf0034239631f7e"
+  integrity sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==
+  dependencies:
+    onetime "^5.1.0"
+    signal-exit "^3.0.2"
+
 ret@~0.1.10:
   version "0.1.15"
   resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc"
@@ -9868,6 +10000,13 @@ run-queue@^1.0.0, run-queue@^1.0.3:
   dependencies:
     aproba "^1.1.1"
 
+rxjs@^6.6.3:
+  version "6.6.3"
+  resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.3.tgz#8ca84635c4daa900c0d3967a6ee7ac60271ee552"
+  integrity sha512-trsQc+xYYXZ3urjOiJOuCOa5N3jAZ3eiSpQB5hIT8zGlL2QfnHLJ2r7GMkBGuIausdJN1OneaI6gQlsqNHHmZQ==
+  dependencies:
+    tslib "^1.9.0"
+
 safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
   version "5.1.2"
   resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
@@ -9980,6 +10119,16 @@ selfsigned@^1.10.7:
   dependencies:
     node-forge "^0.10.0"
 
+semver-compare@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/semver-compare/-/semver-compare-1.0.0.tgz#0dee216a1c941ab37e9efb1788f6afc5ff5537fc"
+  integrity sha1-De4hahyUGrN+nvsXiPavxf9VN/w=
+
+semver-regex@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/semver-regex/-/semver-regex-2.0.0.tgz#a93c2c5844539a770233379107b38c7b4ac9d338"
+  integrity sha512-mUdIBBvdn0PLOeP3TEkMH7HHeUP3GjsXCwKarjv/kGmUFOYg1VqEemKhoQpWMu6X2I8kHeuVdGibLGkVK+/5Qw==
+
 "semver@2 || 3 || 4 || 5", semver@^5.4.1, semver@^5.5.0, semver@^5.5.1, semver@^5.6.0:
   version "5.7.1"
   resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
@@ -10179,6 +10328,24 @@ slice-ansi@^2.1.0:
     astral-regex "^1.0.0"
     is-fullwidth-code-point "^2.0.0"
 
+slice-ansi@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-3.0.0.tgz#31ddc10930a1b7e0b67b08c96c2f49b77a789787"
+  integrity sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==
+  dependencies:
+    ansi-styles "^4.0.0"
+    astral-regex "^2.0.0"
+    is-fullwidth-code-point "^3.0.0"
+
+slice-ansi@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-4.0.0.tgz#500e8dd0fd55b05815086255b3195adf2a45fe6b"
+  integrity sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==
+  dependencies:
+    ansi-styles "^4.0.0"
+    astral-regex "^2.0.0"
+    is-fullwidth-code-point "^3.0.0"
+
 snapdragon-node@^2.0.1:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b"
@@ -10456,6 +10623,11 @@ strict-uri-encode@^1.0.0:
   resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713"
   integrity sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM=
 
+string-argv@0.3.1:
+  version "0.3.1"
+  resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.1.tgz#95e2fbec0427ae19184935f816d74aaa4c5c19da"
+  integrity sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg==
+
 string-length@^4.0.1:
   version "4.0.1"
   resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.1.tgz#4a973bf31ef77c4edbceadd6af2611996985f8a1"
@@ -10820,6 +10992,11 @@ through2@^2.0.0:
     readable-stream "~2.3.6"
     xtend "~4.0.1"
 
+through@^2.3.8:
+  version "2.3.8"
+  resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
+  integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=
+
 thunky@^1.0.2:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/thunky/-/thunky-1.1.0.tgz#5abaf714a9405db0504732bbccd2cedd9ef9537d"
@@ -11526,6 +11703,11 @@ which-module@^2.0.0:
   resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a"
   integrity sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=
 
+which-pm-runs@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/which-pm-runs/-/which-pm-runs-1.0.0.tgz#670b3afbc552e0b55df6b7780ca74615f23ad1cb"
+  integrity sha1-Zws6+8VS4LVd9rd4DKdGFfI60cs=
+
 which@^1.2.9, which@^1.3.1:
   version "1.3.1"
   resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a"