Просмотр исходного кода

feat: start of cmus-type UI with artist / albums / songs browser

Fela Maslen 5 лет назад
Родитель
Сommit
b562dba314

+ 6 - 0
gmus/package.json

@@ -28,6 +28,7 @@
     ]
   },
   "dependencies": {
+    "@react-hook/debounce": "^3.0.0",
     "@react-hook/throttle": "^2.2.0",
     "@testing-library/jest-dom": "^5.11.4",
     "@testing-library/react": "^11.1.0",
@@ -36,18 +37,23 @@
     "@types/node": "^12.0.0",
     "@types/react": "^16.9.53",
     "@types/react-dom": "^16.9.8",
+    "@types/styled-components": "^5.1.5",
+    "axios": "^0.21.0",
     "eslint-config-airbnb-base": "^14.2.1",
     "eslint-config-prettier": "^7.0.0",
     "eslint-plugin-prettier": "^3.3.0",
     "jest-websocket-mock": "^2.2.0",
     "mock-socket": "^9.0.3",
     "nanoid": "^3.1.20",
+    "polished": "^4.0.5",
     "prettier": "^2.2.1",
     "react": "^17.0.1",
     "react-dom": "^17.0.1",
     "react-scripts": "4.0.1",
     "react-spinners": "^0.9.0",
     "react-storage-hooks": "^4.0.1",
+    "styled-components": "^5.2.1",
+    "styled-reset": "^4.3.3",
     "typescript": "^4.0.3",
     "web-vitals": "^0.2.4"
   }

+ 68 - 0
gmus/src/components/app/index.tsx

@@ -0,0 +1,68 @@
+import React, { Dispatch, useCallback, useEffect, useRef } from 'react';
+
+import { LocalAction, stateSet } from '../../actions';
+import { masterStateUpdateTimeout } from '../../constants/system';
+import { DispatchContext, StateContext } from '../../context/state';
+import { useKeepalive } from '../../hooks/socket';
+import { GlobalState } from '../../reducer/types';
+import { getSongUrl } from '../../utils/url';
+import { Player } from '../player';
+import { uiProviders } from '../ui';
+import { UIProvider } from '../ui/types';
+
+export type Props = {
+  socket: WebSocket;
+  state: GlobalState;
+  dispatch: Dispatch<LocalAction>;
+};
+
+function useMaster(dispatch: Dispatch<LocalAction>, isMaster: boolean): void {
+  const masterUpdateTimer = useRef<number>(0);
+  useEffect(() => {
+    if (isMaster) {
+      masterUpdateTimer.current = window.setInterval(() => {
+        dispatch(stateSet());
+      }, masterStateUpdateTimeout);
+    }
+
+    return (): void => {
+      window.clearInterval(masterUpdateTimer.current);
+    };
+  }, [dispatch, isMaster]);
+}
+
+const uiProvider = UIProvider.Cmus;
+const UI = uiProviders[uiProvider];
+
+export const App: React.FC<Props> = ({ socket, state, dispatch }) => {
+  const isMaster = state.player.master === state.myClientName;
+
+  useKeepalive(socket);
+  useMaster(dispatch, isMaster);
+
+  const onTimeUpdate = useCallback(
+    (currentTime: number): void => {
+      dispatch(stateSet({ currentTime }));
+    },
+    [dispatch],
+  );
+
+  return (
+    <>
+      {isMaster && !!state.player.songId && (
+        <Player
+          src={getSongUrl(state.player.songId)}
+          playing={state.player.playing}
+          seekTime={state.player.seekTime}
+          onTimeUpdate={onTimeUpdate}
+          timeUpdateFPS={1}
+        />
+      )}
+      <StateContext.Provider value={state}>
+        <DispatchContext.Provider value={dispatch}>
+          <UI isMaster={isMaster} />
+        </DispatchContext.Provider>
+      </StateContext.Provider>
+    </>
+  );
+};

+ 0 - 43
gmus/src/components/client-list/index.tsx

@@ -1,43 +0,0 @@
-import React, { useCallback } from 'react';
-
-import { useCTA } from '../../hooks/cta';
-import { Member } from '../../types/state';
-
-export type Props = {
-  myClientName: string;
-  clients: Member[];
-};
-
-type PropsListItem = {
-  name: string;
-  onSelect: (name: string) => void;
-};
-
-const ClientListItem: React.FC<PropsListItem> = ({ name, onSelect }) => {
-  const onActivate = useCallback(() => {
-    onSelect(name);
-  }, [onSelect, name]);
-
-  const eventProps = useCTA(onActivate);
-
-  return <li {...eventProps}>{name}</li>;
-};
-
-export const ClientList: React.FC<Props> = ({ myClientName, clients }) => {
-  const onSelectClient = useCallback((name: string) => {
-    console.log('Selected client!', name);
-    // TODO
-  }, []);
-
-  return (
-    <div>
-      <h5>Client list</h5>
-      <h6>My name: {myClientName}</h6>
-      <ul>
-        {clients.map(({ name }) => (
-          <ClientListItem key={name} name={name} onSelect={onSelectClient} />
-        ))}
-      </ul>
-    </div>
-  );
-};

+ 0 - 117
gmus/src/components/gmus/index.tsx

@@ -1,117 +0,0 @@
-import React, { Dispatch, useCallback, useEffect, useRef, useState } from 'react';
-
-import { LocalAction, seeked, stateSet } from '../../actions';
-import { masterStateUpdateTimeout } from '../../constants/system';
-import { useKeepalive } from '../../hooks/socket';
-import { GlobalState } from '../../reducer/types';
-import { getSongUrl } from '../../utils/url';
-import { ClientList } from '../client-list';
-import { Player } from '../player';
-
-export type Props = {
-  socket: WebSocket;
-  state: GlobalState;
-  dispatch: Dispatch<LocalAction>;
-};
-
-function useMaster(dispatch: Dispatch<LocalAction>, isMaster: boolean): void {
-  const masterUpdateTimer = useRef<number>(0);
-  useEffect(() => {
-    if (isMaster) {
-      masterUpdateTimer.current = window.setInterval(() => {
-        dispatch(stateSet());
-      }, masterStateUpdateTimeout);
-    }
-
-    return (): void => {
-      window.clearInterval(masterUpdateTimer.current);
-    };
-  }, [dispatch, isMaster]);
-}
-
-export const Gmus: React.FC<Props> = ({ socket, state, dispatch }) => {
-  useKeepalive(socket);
-
-  const { clientList, player, myClientName } = state;
-
-  const isMaster = player.master === myClientName;
-  useMaster(dispatch, isMaster);
-
-  const [tempSongId, setTempSongId] = useState<number>(0);
-
-  const playSong = useCallback((): void => {
-    if (!tempSongId) {
-      return;
-    }
-
-    dispatch(
-      stateSet({
-        songId: tempSongId,
-        currentTime: 0,
-        playing: true,
-      }),
-    );
-  }, [dispatch, tempSongId]);
-
-  const playPause = useCallback(() => {
-    dispatch(stateSet({ playing: !player.playing }));
-  }, [dispatch, player.playing]);
-
-  const takeControl = useCallback(() => {
-    dispatch(stateSet({ master: myClientName }));
-  }, [dispatch, myClientName]);
-
-  const onTimeUpdate = useCallback(
-    (currentTime: number): void => {
-      dispatch(stateSet({ currentTime }));
-    },
-    [dispatch],
-  );
-
-  const seekTo = useCallback(
-    (time: number) => {
-      dispatch(seeked(time));
-    },
-    [dispatch],
-  );
-
-  return (
-    <div>
-      <div>
-        <button onClick={playPause}>{player.playing ? 'Pause' : 'Play'}</button>
-        {!isMaster && <button onClick={takeControl}>Take control</button>}
-      </div>
-      <div>
-        <input
-          onChange={({ target: { value } }): void => setTempSongId(Number(value))}
-          type="number"
-          min={0}
-          step={1}
-        />
-        <button onClick={playSong}>Change track</button>
-      </div>
-      <div>
-        <input
-          type="number"
-          onBlur={({ target: { value } }): void => seekTo(Number(value))}
-          min={0}
-          step={0.01}
-        />
-      </div>
-      <ClientList myClientName={myClientName} clients={clientList} />
-      <div>
-        <h6>Player State</h6>
-        <pre>{JSON.stringify(player, null, 2)}</pre>
-      </div>
-      {isMaster && !!player.songId && (
-        <Player
-          src={getSongUrl(player.songId)}
-          playing={player.playing}
-          seekTime={player.seekTime}
-          onTimeUpdate={onTimeUpdate}
-          timeUpdateFPS={1}
-        />
-      )}
-    </div>
-  );
-};

+ 2 - 2
gmus/src/components/root/index.tsx

@@ -3,7 +3,7 @@ import { AnyAction, nameSet } from '../../actions';
 
 import { useDispatchWithEffects, useOnMessage, useSocket } from '../../hooks/socket';
 import { globalReducer, GlobalState, init, initialState } from '../../reducer';
-import { Gmus } from '../gmus';
+import { App } from '../app';
 import { Identify } from '../identify';
 
 export const Root: React.FC = () => {
@@ -30,5 +30,5 @@ export const Root: React.FC = () => {
     return <Identify connecting={connecting} onIdentify={onIdentify} />;
   }
 
-  return <Gmus socket={socket} state={state} dispatch={dispatchWithEffects} />;
+  return <App socket={socket} state={state} dispatch={dispatchWithEffects} />;
 };

+ 146 - 0
gmus/src/components/ui/cmus/artists/index.tsx

@@ -0,0 +1,146 @@
+import { useDebounce } from '@react-hook/debounce';
+import React, { useCallback, useContext, useEffect, useState } from 'react';
+
+import { useArtistsAlbumsAndSongs } from '../../../../hooks/fetch/artists';
+import { Keys, useKeyBinding, useVerticalScrollBindings } from '../../../../hooks/vim';
+import { Song } from '../../../../types/songs';
+import { LibraryDispatchContext, LibraryStateContext } from '../context';
+
+import * as Styled from './styles';
+
+type Props = {
+  active: boolean;
+};
+
+type PropsArtist = {
+  artist: string;
+  albums?: string[];
+  songs?: Song[];
+  active: boolean;
+  expanded: boolean;
+  activeAlbum: string | null;
+};
+
+type ArtistKeyBindingsProps = {
+  artist: string;
+  toggleExpanded: (artist: string) => void;
+};
+
+const ArtistKeyBindings: React.FC<ArtistKeyBindingsProps> = ({ artist, toggleExpanded }) => {
+  const toggleMeExpanded = useCallback(() => toggleExpanded(artist), [artist, toggleExpanded]);
+  useKeyBinding(Keys.Space, toggleMeExpanded);
+
+  return null;
+};
+
+const Artist: React.FC<PropsArtist> = ({
+  artist,
+  albums,
+  songs,
+  active,
+  expanded,
+  activeAlbum,
+}) => {
+  const dispatch = useContext(LibraryDispatchContext);
+
+  useEffect(() => {
+    if (active) {
+      dispatch((last) => ({ ...last, visibleSongs: songs ?? [] }));
+    }
+  }, [dispatch, songs, active]);
+
+  return (
+    <Styled.ArtistRow key={artist}>
+      <Styled.ArtistTitle active={active}>{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>
+  );
+};
+
+export const Artists: React.FC<Props> = () => {
+  const dispatch = useContext(LibraryDispatchContext);
+  const { artists, activeArtist, activeAlbum } = useContext(LibraryStateContext);
+
+  const [expandedArtists, setExpandedArtists] = useState<Record<string, boolean>>({});
+
+  const toggleArtistExpanded = useCallback(
+    (artist: string) => setExpandedArtists((last) => ({ ...last, [artist]: !last[artist] })),
+    [],
+  );
+
+  const [debouncedActiveArtist, setDebouncedActiveArtist] = useDebounce(activeArtist, 100);
+  useEffect(() => {
+    setDebouncedActiveArtist(activeArtist);
+  }, [activeArtist, setDebouncedActiveArtist]);
+
+  const [albumsCache, setAlbumsCache] = useState<Record<string, string[]>>({});
+  const [songsCache, setSongsCache] = useState<Record<string, Song[]>>({});
+
+  const { albums, songs } = useArtistsAlbumsAndSongs(
+    debouncedActiveArtist ?? '',
+    !(debouncedActiveArtist && expandedArtists[debouncedActiveArtist]),
+    !debouncedActiveArtist,
+  );
+  useEffect(() => {
+    if (albums) {
+      setAlbumsCache((last) => ({ ...last, [albums.artist]: albums.albums }));
+    }
+    if (songs) {
+      setSongsCache((last) => ({ ...last, [songs.artist]: songs.songs }));
+    }
+  }, [albums, songs]);
+
+  const scrollDelta = useCallback(
+    (delta: number) =>
+      dispatch((last) => ({
+        ...last,
+        activeArtist:
+          last.artists[
+            Math.max(
+              0,
+              Math.min(
+                last.artists.length - 1,
+                last.artists.findIndex((compare) => compare === last.activeArtist) + delta,
+              ),
+            )
+          ],
+      })),
+    [dispatch],
+  );
+
+  const scrollDown = useCallback(() => scrollDelta(1), [scrollDelta]);
+  const scrollUp = useCallback(() => scrollDelta(-1), [scrollDelta]);
+
+  useVerticalScrollBindings(scrollDown, scrollUp);
+
+  return (
+    <Styled.Container>
+      {activeArtist && (
+        <ArtistKeyBindings
+          key={activeArtist}
+          artist={activeArtist}
+          toggleExpanded={toggleArtistExpanded}
+        />
+      )}
+      {artists.map((artist) => (
+        <Artist
+          key={artist}
+          artist={artist}
+          albums={albumsCache[artist]}
+          songs={songsCache[artist]}
+          active={artist === activeArtist}
+          expanded={!!expandedArtists[artist]}
+          activeAlbum={activeAlbum}
+        />
+      ))}
+    </Styled.Container>
+  );
+};

+ 20 - 0
gmus/src/components/ui/cmus/artists/styles.ts

@@ -0,0 +1,20 @@
+import { rem } from 'polished';
+import styled from 'styled-components';
+
+import { ActiveHighlightRow, FlexColumn } from '../shared/layout';
+
+export const Container = styled(FlexColumn)`
+  flex: 1;
+`;
+
+export const ArtistRow = styled(FlexColumn)``;
+
+export const ArtistTitle = styled(ActiveHighlightRow)``;
+
+export const ArtistAlbums = styled.ul`
+  list-style: none;
+  margin: 0;
+  padding: 0 0 0 ${rem(16)};
+`;
+
+export const AlbumTitle = styled(ActiveHighlightRow)``;

+ 17 - 0
gmus/src/components/ui/cmus/context.ts

@@ -0,0 +1,17 @@
+import { createContext, Dispatch, SetStateAction } from 'react';
+
+import { nullDispatch } from '../../../context/state';
+import { LibraryState, ModeWindow } from './types';
+
+export const initialLibraryState: LibraryState = {
+  artists: [],
+  activeArtist: null,
+  activeAlbum: null,
+  modeWindow: ModeWindow.ArtistList,
+  visibleSongs: [],
+};
+
+export const LibraryStateContext = createContext<LibraryState>(initialLibraryState);
+export const LibraryDispatchContext = createContext<Dispatch<SetStateAction<LibraryState>>>(
+  nullDispatch,
+);

+ 36 - 0
gmus/src/components/ui/cmus/index.tsx

@@ -0,0 +1,36 @@
+import React, { useCallback, useMemo, useState } from 'react';
+import {
+  BoundAction,
+  KeyPressedContext,
+  useMultiKeyBinding,
+  useVimBindings,
+} from '../../../hooks/vim';
+
+import { UIProviderComponent } from '../types';
+
+import * as Styled from './styles';
+import { ViewLibrary } from './views/library';
+
+enum View {
+  Library,
+}
+
+export const CmusUIProvider: UIProviderComponent = () => {
+  const lastKeyPress = useVimBindings();
+
+  const [view, setView] = useState<View>(View.Library);
+
+  const setLibrary = useCallback(() => setView(View.Library), []);
+
+  const boundActions = useMemo<BoundAction[]>(() => [{ binding: '1', action: setLibrary }], [
+    setLibrary,
+  ]);
+
+  useMultiKeyBinding(boundActions);
+
+  return (
+    <KeyPressedContext.Provider value={lastKeyPress}>
+      <Styled.Wrapper>{view === View.Library && <ViewLibrary />}</Styled.Wrapper>
+    </KeyPressedContext.Provider>
+  );
+};

+ 20 - 0
gmus/src/components/ui/cmus/shared/layout.ts

@@ -0,0 +1,20 @@
+import { CSSProperties } from 'react';
+import styled from 'styled-components';
+
+import { colors } from '../variables';
+
+export const FlexRow = styled.div`
+  display: flex;
+`;
+
+export const FlexColumn = styled(FlexRow)`
+  flex-flow: column;
+`;
+
+export const ActiveHighlightRow = styled(FlexRow)<{ active?: boolean; highlight?: boolean }>`
+  background: ${({ active }): string => (active ? colors.selected.background : 'none')};
+  color: ${({ highlight }): string => (highlight ? colors.active.color : colors.foreground)};
+  font-weight: ${({ active, highlight }): CSSProperties['fontWeight'] =>
+    active || highlight ? 'bold' : 'normal'};
+  width: 100%;
+`;

+ 23 - 0
gmus/src/components/ui/cmus/songs/index.tsx

@@ -0,0 +1,23 @@
+import React, { useContext } from 'react';
+
+import { LibraryStateContext } from '../context';
+
+import * as Styled from './styles';
+
+type Props = {
+  active: boolean;
+};
+
+export const Songs: React.FC<Props> = () => {
+  const { visibleSongs: songs } = useContext(LibraryStateContext);
+
+  return (
+    <Styled.Container>
+      {songs.map((song) => (
+        <Styled.Song key={song.id}>
+          {song.track} - {song.title || 'Untitled Track'}
+        </Styled.Song>
+      ))}
+    </Styled.Container>
+  );
+};

+ 13 - 0
gmus/src/components/ui/cmus/songs/styles.ts

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

+ 19 - 0
gmus/src/components/ui/cmus/styles.ts

@@ -0,0 +1,19 @@
+import { rem } from 'polished';
+import styled from 'styled-components';
+
+import { FlexRow } from './shared/layout';
+import { colors } from './variables';
+
+export const Wrapper = styled(FlexRow)`
+  background: ${colors.background};
+  bottom: 0;
+  color: ${colors.foreground};
+  font-size: ${rem(14)};
+  font-family: Hack, monospace;
+  left: 0;
+  line-height: ${rem(16)};
+  position: absolute;
+  right: 0;
+  top: 0;
+  user-select: none;
+`;

+ 14 - 0
gmus/src/components/ui/cmus/types.ts

@@ -0,0 +1,14 @@
+import { Song } from '../../../types/songs';
+
+export enum ModeWindow {
+  ArtistList,
+  SongList,
+}
+
+export type LibraryState = {
+  artists: string[];
+  activeArtist: string | null;
+  activeAlbum: string | null;
+  modeWindow: ModeWindow;
+  visibleSongs: Song[];
+};

+ 13 - 0
gmus/src/components/ui/cmus/variables.ts

@@ -0,0 +1,13 @@
+import { rgb } from 'polished';
+
+export const colors = {
+  background: rgb(0, 0, 0),
+  border: rgb(0, 0, 180),
+  foreground: rgb(255, 255, 255),
+  selected: {
+    background: rgb(0, 0, 180),
+  },
+  active: {
+    color: rgb(255, 255, 130),
+  },
+};

+ 38 - 0
gmus/src/components/ui/cmus/views/library.tsx

@@ -0,0 +1,38 @@
+import React, { useCallback, useEffect, useState } from 'react';
+
+import { useArtists } from '../../../../hooks/fetch/artists';
+import { useKeyBinding } from '../../../../hooks/vim';
+
+import { Artists } from '../artists';
+import { initialLibraryState, LibraryDispatchContext, LibraryStateContext } from '../context';
+import { Songs } from '../songs';
+import { LibraryState, ModeWindow } from '../types';
+
+const modeWindows: ModeWindow[] = Object.values(ModeWindow) as ModeWindow[];
+
+export const ViewLibrary: React.FC = () => {
+  const [state, dispatch] = useState<LibraryState>(initialLibraryState);
+
+  const switchModeWindow = useCallback(() => {
+    dispatch((last) => ({
+      ...last,
+      modeWindow: modeWindows[(modeWindows.indexOf(last.modeWindow) + 1) % modeWindows.length],
+    }));
+  }, []);
+
+  useKeyBinding('Tab', switchModeWindow);
+
+  const { artists } = useArtists();
+  useEffect(() => {
+    dispatch((last) => ({ ...last, artists, activeArtist: artists[0] }));
+  }, [artists]);
+
+  return (
+    <LibraryStateContext.Provider value={state}>
+      <LibraryDispatchContext.Provider value={dispatch}>
+        <Artists active={state.modeWindow === ModeWindow.ArtistList} />
+        <Songs active={state.modeWindow === ModeWindow.SongList} />
+      </LibraryDispatchContext.Provider>
+    </LibraryStateContext.Provider>
+  );
+};

+ 6 - 0
gmus/src/components/ui/index.ts

@@ -0,0 +1,6 @@
+import { CmusUIProvider } from './cmus';
+import { UIProvider, UIProviders } from './types';
+
+export const uiProviders: UIProviders = {
+  [UIProvider.Cmus]: CmusUIProvider,
+};

+ 13 - 0
gmus/src/components/ui/types.ts

@@ -0,0 +1,13 @@
+import React from 'react';
+
+export enum UIProvider {
+  Cmus = 'Cmus',
+}
+
+export type UIProps = {
+  isMaster: boolean;
+};
+
+export type UIProviderComponent = React.FC<UIProps>;
+
+export type UIProviders = Record<UIProvider, UIProviderComponent>;

+ 12 - 0
gmus/src/context/state.ts

@@ -0,0 +1,12 @@
+import { createContext, Dispatch } from 'react';
+import { LocalAction } from '../actions';
+
+import { GlobalState, initialState } from '../reducer';
+
+export const StateContext = createContext<GlobalState>(initialState);
+
+export const nullDispatch: Dispatch<unknown> = (): void => {
+  // pass
+};
+
+export const DispatchContext = createContext<Dispatch<LocalAction>>(nullDispatch);

+ 138 - 0
gmus/src/hooks/fetch/artists.ts

@@ -0,0 +1,138 @@
+import { AxiosInstance, AxiosResponse } from 'axios';
+import { useCallback, useMemo, useState } from 'react';
+
+import { Song } from '../../types/songs';
+import { getApiUrl } from '../../utils/url';
+import { useCancellableRequest } from '../request';
+
+type ArtistsResponse = {
+  artists: string[];
+  more: boolean;
+};
+
+type ArtistsQuery = {
+  limit: number;
+  page: number;
+};
+
+const sendArtistsRequest = (
+  axios: AxiosInstance,
+  query: ArtistsQuery,
+): Promise<AxiosResponse<ArtistsResponse>> =>
+  axios.get(`${getApiUrl()}/artists?limit=${query.limit}&page=${query.page}`);
+
+type AlbumsResponse = {
+  artist: string;
+  albums: string[];
+};
+
+type AlbumsQuery = {
+  artist: string;
+};
+
+const sendAlbumsRequest = (
+  axios: AxiosInstance,
+  query: AlbumsQuery,
+): Promise<AxiosResponse<AlbumsResponse>> =>
+  axios.get(`${getApiUrl()}/albums?artist=${query.artist}`);
+
+type SongsResponse = {
+  artist: string;
+  songs: Song[];
+};
+
+type SongsQuery = AlbumsQuery;
+
+const sendSongsRequest = (
+  axios: AxiosInstance,
+  query: SongsQuery,
+): Promise<AxiosResponse<SongsResponse>> =>
+  axios.get(`${getApiUrl()}/songs?artist=${query.artist}`);
+
+export function useArtists(): ArtistsResponse & {
+  fetching: boolean;
+  fetchMore: () => void;
+} {
+  const [limit] = useState<number>(100);
+  const [page, setPage] = useState<number>(0);
+
+  const [artists, setArtists] = useState<string[]>([]);
+  const [more, setMore] = useState<boolean>(true);
+
+  const [pause, setPause] = useState<boolean>(false);
+
+  const query = useMemo<ArtistsQuery>(() => ({ limit, page }), [limit, page]);
+
+  const handleResponse = useCallback((response: ArtistsResponse) => {
+    setMore(response.more);
+    setArtists((last) => Array.from(new Set([...last, ...response.artists])));
+    setPause(true);
+  }, []);
+
+  const fetching = useCancellableRequest<ArtistsQuery, ArtistsResponse>({
+    query,
+    pause,
+    sendRequest: sendArtistsRequest,
+    handleResponse,
+  });
+
+  const fetchMore = useCallback(() => {
+    if (!more) {
+      return;
+    }
+    setPage((last) => last + 1);
+    setPause(false);
+  }, [more]);
+
+  return { artists, more, fetching, fetchMore };
+}
+
+export function useArtistsAlbumsAndSongs(
+  artist: string,
+  pauseAlbums: boolean,
+  pauseSongs: boolean,
+): {
+  albums: AlbumsResponse | undefined;
+  songs: SongsResponse | undefined;
+  fetchingAlbums: boolean;
+  fetchingSongs: boolean;
+} {
+  const [hasLoadedAlbums, setHasLoadedAlbums] = useState<Record<string, boolean>>({});
+  const [hasLoadedSongs, setHasLoadedSongs] = useState<Record<string, boolean>>({});
+
+  const query = useMemo<AlbumsQuery>(() => ({ artist }), [artist]);
+
+  const [albums, setAlbums] = useState<AlbumsResponse | undefined>();
+  const [songs, setSongs] = useState<SongsResponse | undefined>();
+
+  const handleAlbumsResponse = useCallback((response: AlbumsResponse) => {
+    setAlbums(response);
+    setHasLoadedAlbums((last) => ({ ...last, [response.artist]: true }));
+  }, []);
+
+  const handleSongsResponse = useCallback((response: SongsResponse) => {
+    setSongs(response);
+    setHasLoadedSongs((last) => ({ ...last, [response.artist]: true }));
+  }, []);
+
+  const fetchingAlbums = useCancellableRequest<AlbumsQuery, AlbumsResponse>({
+    query,
+    pause: pauseAlbums || hasLoadedAlbums[artist],
+    sendRequest: sendAlbumsRequest,
+    handleResponse: handleAlbumsResponse,
+  });
+
+  const fetchingSongs = useCancellableRequest<SongsQuery, SongsResponse>({
+    query,
+    pause: pauseSongs || hasLoadedSongs[artist],
+    sendRequest: sendSongsRequest,
+    handleResponse: handleSongsResponse,
+  });
+
+  return {
+    albums,
+    songs,
+    fetchingAlbums,
+    fetchingSongs,
+  };
+}

+ 72 - 0
gmus/src/hooks/request.ts

@@ -0,0 +1,72 @@
+import axios, { Canceler, AxiosInstance, AxiosResponse } from 'axios';
+import { useEffect, useRef, useState } from 'react';
+
+type Options<Query, Response> = {
+  query: Query;
+  pause?: boolean;
+  sendRequest: (axiosInstance: AxiosInstance, query: Query) => Promise<AxiosResponse<Response>>;
+  handleResponse: (res: Response, query: Query) => void;
+  onError?: (err: Error) => void;
+  onClear?: () => void;
+  debounceDelay?: number;
+};
+
+export function useCancellableRequest<Query, Response = void>({
+  query,
+  pause,
+  sendRequest,
+  handleResponse,
+  onError,
+  onClear,
+}: Options<Query, Response>): boolean {
+  const [loading, setLoading] = useState<boolean>(false);
+
+  const cancelRequest = useRef<Canceler>();
+
+  useEffect(() => {
+    setLoading(!!query);
+    if (!query) {
+      onClear?.();
+    }
+  }, [query, onClear]);
+
+  useEffect(() => {
+    let cancelled = false;
+    const request = async (): Promise<void> => {
+      try {
+        const axiosWithToken = axios.create({
+          cancelToken: new axios.CancelToken((token): void => {
+            cancelRequest.current = token;
+          }),
+        });
+        const res = await sendRequest(axiosWithToken, query);
+        if (cancelled) {
+          return;
+        }
+
+        handleResponse(res.data, query);
+      } catch (err) {
+        if (!axios.isCancel(err)) {
+          onError?.(err);
+        }
+      } finally {
+        if (!cancelled) {
+          setLoading(false);
+        }
+      }
+    };
+
+    if (!pause) {
+      request();
+    }
+
+    return (): void => {
+      cancelled = true;
+      if (cancelRequest.current) {
+        cancelRequest.current();
+      }
+    };
+  }, [sendRequest, handleResponse, onError, query, pause]);
+
+  return loading;
+}

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

@@ -0,0 +1,59 @@
+import { createContext, useContext, useEffect, useMemo, useState } from 'react';
+
+export const KeyPressedContext = createContext<string | null>(null);
+
+export enum Keys {
+  Space = ' ',
+  Down = 'j',
+  Up = 'k',
+}
+
+export function useVimBindings(): string | null {
+  const [state, dispatch] = useState<string | null>(null);
+  useEffect(() => {
+    const listener = (event: KeyboardEvent): void => {
+      dispatch(event.key);
+      dispatch(null);
+    };
+    window.addEventListener('keydown', listener);
+    return (): void => {
+      window.removeEventListener('keydown', listener);
+    };
+  }, []);
+
+  return state;
+}
+
+export function useKeyBinding(binding: string, action: () => void): void {
+  const lastKeyPress = useContext(KeyPressedContext);
+  useEffect(() => {
+    if (lastKeyPress === binding) {
+      action();
+    }
+  }, [binding, action, lastKeyPress]);
+}
+
+export type BoundAction = {
+  binding: string;
+  action: () => void;
+};
+
+export function useMultiKeyBinding(boundActions: BoundAction[]): void {
+  const lastKeyPress = useContext(KeyPressedContext);
+  useEffect(() => {
+    if (lastKeyPress) {
+      boundActions.find(({ binding }) => binding === lastKeyPress)?.action();
+    }
+  }, [boundActions, lastKeyPress]);
+}
+
+export function useVerticalScrollBindings(scrollDown: () => void, scrollUp: () => void): void {
+  const boundActions = useMemo<BoundAction[]>(
+    () => [
+      { binding: Keys.Down, action: scrollDown },
+      { binding: Keys.Up, action: scrollUp },
+    ],
+    [scrollDown, scrollUp],
+  );
+  useMultiKeyBinding(boundActions);
+}

+ 3 - 0
gmus/src/index.tsx

@@ -1,10 +1,13 @@
 import React from 'react';
 import ReactDOM from 'react-dom';
+import { Reset } from 'styled-reset';
+
 import { Root } from './components/root';
 import reportWebVitals from './reportWebVitals';
 
 ReactDOM.render(
   <React.StrictMode>
+    <Reset />
     <Root />
   </React.StrictMode>,
   document.getElementById('root'),

+ 8 - 0
gmus/src/types/songs.ts

@@ -0,0 +1,8 @@
+export type Song = {
+  id: number;
+  track: number | null;
+  title: string;
+  artist: string;
+  album: string;
+  time: number;
+};

+ 5 - 1
gmus/src/utils/url.ts

@@ -1,5 +1,9 @@
+export function getApiUrl(): string {
+  return process.env.REACT_APP_API_URL ?? 'http://localhost:3000';
+}
+
 export function getPubsubUrl(): string {
-  const apiUrl = new URL(process.env.REACT_APP_API_URL ?? 'http://localhost:3000');
+  const apiUrl = new URL(getApiUrl());
   return `${apiUrl.protocol === 'https:' ? 'wss' : 'ws'}://${apiUrl.hostname}${
     apiUrl.port ? `:${apiUrl.port}` : ''
   }/pubsub`;

+ 177 - 4
gmus/yarn.lock

@@ -67,6 +67,22 @@
     jsesc "^2.5.1"
     source-map "^0.5.0"
 
+"@babel/generator@^7.12.10":
+  version "7.12.10"
+  resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.12.10.tgz#2b188fc329fb8e4f762181703beffc0fe6df3460"
+  integrity sha512-6mCdfhWgmqLdtTkhXjnIz0LcdVCd26wS2JXRtj2XY0u5klDsXBREA/pG5NVOuVnF2LUrBGNFtQkIqqTbblg0ww==
+  dependencies:
+    "@babel/types" "^7.12.10"
+    jsesc "^2.5.1"
+    source-map "^0.5.0"
+
+"@babel/helper-annotate-as-pure@^7.0.0":
+  version "7.12.10"
+  resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.12.10.tgz#54ab9b000e60a93644ce17b3f37d313aaf1d115d"
+  integrity sha512-XplmVbC1n+KY6jL8/fgLVXXUauDIB+lD5+GsQEh6F6GBF1dq1qy4DP4yXWzDKcoqXB3X58t61e85Fitoww4JVQ==
+  dependencies:
+    "@babel/types" "^7.12.10"
+
 "@babel/helper-annotate-as-pure@^7.10.4":
   version "7.10.4"
   resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.10.4.tgz#5bf0d495a3f757ac3bda48b5bf3b3ba309c72ba3"
@@ -291,6 +307,11 @@
   resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.12.7.tgz#fee7b39fe809d0e73e5b25eecaf5780ef3d73056"
   integrity sha512-oWR02Ubp4xTLCAqPRiNIuMVgNO5Aif/xpXtabhzW2HWUD47XJsAB4Zd/Rg30+XeQA3juXigV7hlquOTmwqLiwg==
 
+"@babel/parser@^7.12.10":
+  version "7.12.10"
+  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.12.10.tgz#824600d59e96aea26a5a2af5a9d812af05c3ae81"
+  integrity sha512-PJdRPwyoOqFAWfLytxrWwGrAxghCgh/yTNCYciOz8QgjflA7aZhECPZAa2VUedKg2+QMWkI0L9lynh2SNmNEgA==
+
 "@babel/plugin-proposal-async-generator-functions@^7.12.1":
   version "7.12.1"
   resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.12.1.tgz#dc6c1170e27d8aca99ff65f4925bd06b1c90550e"
@@ -1116,6 +1137,21 @@
     globals "^11.1.0"
     lodash "^4.17.19"
 
+"@babel/traverse@^7.4.5":
+  version "7.12.10"
+  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.12.10.tgz#2d1f4041e8bf42ea099e5b2dc48d6a594c00017a"
+  integrity sha512-6aEtf0IeRgbYWzta29lePeYSk+YAFIC3kyqESeft8o5CkFlYIMX+EQDDWEiAQ9LHOA3d0oHdgrSsID/CKqXJlg==
+  dependencies:
+    "@babel/code-frame" "^7.10.4"
+    "@babel/generator" "^7.12.10"
+    "@babel/helper-function-name" "^7.10.4"
+    "@babel/helper-split-export-declaration" "^7.11.0"
+    "@babel/parser" "^7.12.10"
+    "@babel/types" "^7.12.10"
+    debug "^4.1.0"
+    globals "^11.1.0"
+    lodash "^4.17.19"
+
 "@babel/types@^7.0.0", "@babel/types@^7.10.4", "@babel/types@^7.10.5", "@babel/types@^7.11.0", "@babel/types@^7.12.1", "@babel/types@^7.12.5", "@babel/types@^7.12.6", "@babel/types@^7.12.7", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4", "@babel/types@^7.7.0":
   version "7.12.7"
   resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.12.7.tgz#6039ff1e242640a29452c9ae572162ec9a8f5d13"
@@ -1125,6 +1161,15 @@
     lodash "^4.17.19"
     to-fast-properties "^2.0.0"
 
+"@babel/types@^7.12.10":
+  version "7.12.10"
+  resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.12.10.tgz#7965e4a7260b26f09c56bcfcb0498af1f6d9b260"
+  integrity sha512-sf6wboJV5mGyip2hIpDSKsr80RszPinEFjsHTalMxZAZkoQ2/2yQzxlcFN52SJqsyPfLtPmenL4g2KB3KJXPDw==
+  dependencies:
+    "@babel/helper-validator-identifier" "^7.10.4"
+    lodash "^4.17.19"
+    to-fast-properties "^2.0.0"
+
 "@bcoe/v8-coverage@^0.2.3":
   version "0.2.3"
   resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
@@ -1184,6 +1229,13 @@
   resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.8.0.tgz#bbbff68978fefdbe68ccb533bc8cbe1d1afb5413"
   integrity sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==
 
+"@emotion/is-prop-valid@^0.8.8":
+  version "0.8.8"
+  resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz#db28b1c4368a259b60a97311d6a952d4fd01ac1a"
+  integrity sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==
+  dependencies:
+    "@emotion/memoize" "0.7.4"
+
 "@emotion/memoize@0.7.4":
   version "0.7.4"
   resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.7.4.tgz#19bf0f5af19149111c40d98bb0cf82119f5d9eeb"
@@ -1205,12 +1257,12 @@
   resolved "https://registry.yarnpkg.com/@emotion/sheet/-/sheet-0.9.4.tgz#894374bea39ec30f489bbfc3438192b9774d32e5"
   integrity sha512-zM9PFmgVSqBw4zL101Q0HrBVTGmpAxFZH/pYx/cjJT5advXguvcgjHFTCaIO3enL/xr89vK2bh0Mfyj9aa0ANA==
 
-"@emotion/stylis@0.8.5":
+"@emotion/stylis@0.8.5", "@emotion/stylis@^0.8.4":
   version "0.8.5"
   resolved "https://registry.yarnpkg.com/@emotion/stylis/-/stylis-0.8.5.tgz#deacb389bd6ee77d1e7fcaccce9e16c5c7e78e04"
   integrity sha512-h6KtPihKFn3T9fuIrwvXXUOwlx3rfUvfZIcP5a6rh8Y7zjE3O06hT5Ss4S/YI1AYhuZ1kjaE/5EaOOI2NqSylQ==
 
-"@emotion/unitless@0.7.5":
+"@emotion/unitless@0.7.5", "@emotion/unitless@^0.7.4":
   version "0.7.5"
   resolved "https://registry.yarnpkg.com/@emotion/unitless/-/unitless-0.7.5.tgz#77211291c1900a700b8a78cfafda3160d76949ed"
   integrity sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==
@@ -1500,6 +1552,13 @@
     schema-utils "^2.6.5"
     source-map "^0.7.3"
 
+"@react-hook/debounce@^3.0.0":
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/@react-hook/debounce/-/debounce-3.0.0.tgz#9eea8b5d81d4cb67cd72dd8657b3ff724afc7cad"
+  integrity sha512-ir/kPrSfAzY12Gre0sOHkZ2rkEmM4fS5M5zFxCi4BnCeXh2nvx9Ujd+U4IGpKCuPA+EQD0pg1eK2NGLvfWejag==
+  dependencies:
+    "@react-hook/latest" "^1.0.2"
+
 "@react-hook/latest@^1.0.2":
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/@react-hook/latest/-/latest-1.0.3.tgz#c2d1d0b0af8b69ec6e2b3a2412ba0768ac82db80"
@@ -1784,6 +1843,14 @@
   dependencies:
     "@types/node" "*"
 
+"@types/hoist-non-react-statics@*":
+  version "3.3.1"
+  resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz#1124aafe5118cb591977aeb1ceaaed1070eb039f"
+  integrity sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==
+  dependencies:
+    "@types/react" "*"
+    hoist-non-react-statics "^3.3.0"
+
 "@types/html-minifier-terser@^5.0.0":
   version "5.1.1"
   resolved "https://registry.yarnpkg.com/@types/html-minifier-terser/-/html-minifier-terser-5.1.1.tgz#3c9ee980f1a10d6021ae6632ca3e79ca2ec4fb50"
@@ -1873,6 +1940,21 @@
   dependencies:
     "@types/react" "^16"
 
+"@types/react-native@*":
+  version "0.63.40"
+  resolved "https://registry.yarnpkg.com/@types/react-native/-/react-native-0.63.40.tgz#66b11824a6aadb0dde0db87bd8aff58165c7a8f8"
+  integrity sha512-y98TQBjfncIrdDrwIhxcmcad1gHKNfUKFnCBk3heYy0Gvt6BNcBeSKRvACV4cf6R1K/cNJMoS3rjaMvYhQyAgw==
+  dependencies:
+    "@types/react" "*"
+
+"@types/react@*":
+  version "17.0.0"
+  resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.0.tgz#5af3eb7fad2807092f0046a1302b7823e27919b8"
+  integrity sha512-aj/L7RIMsRlWML3YB6KZiXB3fV2t41+5RBGYF8z+tAKU43Px8C3cYUZsDvf1/+Bm4FK21QWBrDutu8ZJ/70qOw==
+  dependencies:
+    "@types/prop-types" "*"
+    csstype "^3.0.2"
+
 "@types/react@^16", "@types/react@^16.9.53":
   version "16.14.2"
   resolved "https://registry.yarnpkg.com/@types/react/-/react-16.14.2.tgz#85dcc0947d0645349923c04ccef6018a1ab7538c"
@@ -1898,6 +1980,16 @@
   resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.0.tgz#7036640b4e21cc2f259ae826ce843d277dad8cff"
   integrity sha512-RJJrrySY7A8havqpGObOB4W92QXKJo63/jFLLgpvOtsGUqbQZ9Sbgl35KMm1DjC6j7AvmmU2bIno+3IyEaemaw==
 
+"@types/styled-components@^5.1.5":
+  version "5.1.5"
+  resolved "https://registry.yarnpkg.com/@types/styled-components/-/styled-components-5.1.5.tgz#a98e2ead1a1c7660b966f3aade4df9c0ec8ed75a"
+  integrity sha512-VWwoKm39W783a75oILtdCjnNn+FSV8Yh3O9naIudtZCAkk+rRfwLWzuPjf5k/OilENgZqAkxJ9sukSOZTAD86g==
+  dependencies:
+    "@types/hoist-non-react-statics" "*"
+    "@types/react" "*"
+    "@types/react-native" "*"
+    csstype "^3.0.2"
+
 "@types/tapable@*", "@types/tapable@^1.0.5":
   version "1.0.6"
   resolved "https://registry.yarnpkg.com/@types/tapable/-/tapable-1.0.6.tgz#a9ca4b70a18b270ccb2bc0aaafefd1d486b7ea74"
@@ -2581,6 +2673,13 @@ axe-core@^4.0.2:
   resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.1.1.tgz#70a7855888e287f7add66002211a423937063eaf"
   integrity sha512-5Kgy8Cz6LPC9DJcNb3yjAXTu3XihQgEdnIg50c//zOC/MyLP0Clg+Y8Sh9ZjjnvBrDZU4DgXS9C3T9r4/scGZQ==
 
+axios@^0.21.0:
+  version "0.21.0"
+  resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.0.tgz#26df088803a2350dff2c27f96fef99fe49442aca"
+  integrity sha512-fmkJBknJKoZwem3/IKSSLpkdNXZeBu5Q7GA/aRsr2btgrptmSCxi2oFjZHqGdK9DoTil9PIHlPIZw2EcRJXRvw==
+  dependencies:
+    follow-redirects "^1.10.0"
+
 axobject-query@^2.2.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-2.2.0.tgz#943d47e10c0b704aa42275e20edf3722648989be"
@@ -2688,6 +2787,16 @@ babel-plugin-named-asset-import@^0.3.7:
   resolved "https://registry.yarnpkg.com/babel-plugin-named-asset-import/-/babel-plugin-named-asset-import-0.3.7.tgz#156cd55d3f1228a5765774340937afc8398067dd"
   integrity sha512-squySRkf+6JGnvjoUtDEjSREJEBirnXi9NqP6rjSYsylxQxqBTz+pkmf395i9E2zsvmYUaI40BHo6SqZUdydlw==
 
+"babel-plugin-styled-components@>= 1":
+  version "1.12.0"
+  resolved "https://registry.yarnpkg.com/babel-plugin-styled-components/-/babel-plugin-styled-components-1.12.0.tgz#1dec1676512177de6b827211e9eda5a30db4f9b9"
+  integrity sha512-FEiD7l5ZABdJPpLssKXjBUJMYqzbcNzBowfXDCdJhOpbhWiewapUaY+LZGT8R4Jg2TwOjGjG4RKeyrO5p9sBkA==
+  dependencies:
+    "@babel/helper-annotate-as-pure" "^7.0.0"
+    "@babel/helper-module-imports" "^7.0.0"
+    babel-plugin-syntax-jsx "^6.18.0"
+    lodash "^4.17.11"
+
 babel-plugin-syntax-jsx@^6.18.0:
   version "6.18.0"
   resolved "https://registry.yarnpkg.com/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz#0af32a9a6e13ca7a3fd5069e62d7b0f58d0d8946"
@@ -3169,6 +3278,11 @@ camelcase@^6.0.0, camelcase@^6.1.0, camelcase@^6.2.0:
   resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.2.0.tgz#924af881c9d525ac9d87f40d964e5cea982a1809"
   integrity sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg==
 
+camelize@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/camelize/-/camelize-1.0.0.tgz#164a5483e630fa4321e5af07020e531831b2609b"
+  integrity sha1-FkpUg+Yw+kMh5a8HAg5TGDGyYJs=
+
 caniuse-api@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/caniuse-api/-/caniuse-api-3.0.0.tgz#5e4d90e2274961d46291997df599e3ed008ee4c0"
@@ -3716,6 +3830,11 @@ css-blank-pseudo@^0.1.4:
   dependencies:
     postcss "^7.0.5"
 
+css-color-keywords@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/css-color-keywords/-/css-color-keywords-1.0.0.tgz#fea2616dc676b2962686b3af8dbdbe180b244e05"
+  integrity sha1-/qJhbcZ2spYmhrOvjb2+GAskTgU=
+
 css-color-names@0.0.4, css-color-names@^0.0.4:
   version "0.0.4"
   resolved "https://registry.yarnpkg.com/css-color-names/-/css-color-names-0.0.4.tgz#808adc2e79cf84738069b646cb20ec27beb629e0"
@@ -3787,6 +3906,15 @@ css-select@^2.0.0:
     domutils "^1.7.0"
     nth-check "^1.0.2"
 
+css-to-react-native@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/css-to-react-native/-/css-to-react-native-3.0.0.tgz#62dbe678072a824a689bcfee011fc96e02a7d756"
+  integrity sha512-Ro1yETZA813eoyUp2GDBhG2j+YggidUmzO1/v9eYBKR2EHVEniE2MI/NqpTQ954BMpTPZFsGNPm46qFB9dpaPQ==
+  dependencies:
+    camelize "^1.0.0"
+    css-color-keywords "^1.0.0"
+    postcss-value-parser "^4.0.2"
+
 css-tree@1.0.0-alpha.37:
   version "1.0.0-alpha.37"
   resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-1.0.0-alpha.37.tgz#98bebd62c4c1d9f960ec340cf9f7522e30709a22"
@@ -5178,6 +5306,11 @@ follow-redirects@^1.0.0:
   resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.0.tgz#b42e8d93a2a7eea5ed88633676d6597bc8e384db"
   integrity sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA==
 
+follow-redirects@^1.10.0:
+  version "1.13.1"
+  resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.1.tgz#5f69b813376cee4fd0474a3aba835df04ab763b7"
+  integrity sha512-SSG5xmZh1mkPGyKzjZP8zLjltIfpW32Y5QpdNJyjcfGxK3qo3NDDkZOZSFiGn1A6SclQxY9GzEwAHQ3dmYRWpg==
+
 for-in@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80"
@@ -5576,6 +5709,13 @@ hmac-drbg@^1.0.0:
     minimalistic-assert "^1.0.0"
     minimalistic-crypto-utils "^1.0.1"
 
+hoist-non-react-statics@^3.0.0, hoist-non-react-statics@^3.3.0:
+  version "3.3.2"
+  resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45"
+  integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==
+  dependencies:
+    react-is "^16.7.0"
+
 hoopy@^0.1.4:
   version "0.1.4"
   resolved "https://registry.yarnpkg.com/hoopy/-/hoopy-0.1.4.tgz#609207d661100033a9a9402ad3dea677381c1b1d"
@@ -8156,6 +8296,13 @@ pnp-webpack-plugin@1.6.4:
   dependencies:
     ts-pnp "^1.1.6"
 
+polished@^4.0.5:
+  version "4.0.5"
+  resolved "https://registry.yarnpkg.com/polished/-/polished-4.0.5.tgz#3f91873c8f72dec1723b3f892f57fbb22645b23d"
+  integrity sha512-BY2+LVtOHQWBQpGN4GPAKpCdsBePOdSdHTpZegRDRCrvGPkRPTx1DEC+vGjIDPhXS7W2qiBxschnwRWTFdMZag==
+  dependencies:
+    "@babel/runtime" "^7.12.5"
+
 portfinder@^1.0.26:
   version "1.0.28"
   resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.28.tgz#67c4622852bd5374dd1dd900f779f53462fac778"
@@ -9137,7 +9284,7 @@ react-error-overlay@^6.0.8:
   resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.8.tgz#474ed11d04fc6bda3af643447d85e9127ed6b5de"
   integrity sha512-HvPuUQnLp5H7TouGq3kzBeioJmXms1wHy9EGjz2OURWBp4qZO6AfGEcnxts1D/CbwPLRAgTMPCEgYhA3sEM4vw==
 
-react-is@^16.8.1:
+react-is@^16.7.0, react-is@^16.8.1:
   version "16.13.1"
   resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
   integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
@@ -9912,6 +10059,11 @@ shallow-clone@^3.0.0:
   dependencies:
     kind-of "^6.0.2"
 
+shallowequal@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8"
+  integrity sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==
+
 shebang-command@^1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea"
@@ -10414,6 +10566,27 @@ style-loader@1.3.0:
     loader-utils "^2.0.0"
     schema-utils "^2.7.0"
 
+styled-components@^5.2.1:
+  version "5.2.1"
+  resolved "https://registry.yarnpkg.com/styled-components/-/styled-components-5.2.1.tgz#6ed7fad2dc233825f64c719ffbdedd84ad79101a"
+  integrity sha512-sBdgLWrCFTKtmZm/9x7jkIabjFNVzCUeKfoQsM6R3saImkUnjx0QYdLwJHBjY9ifEcmjDamJDVfknWm1yxZPxQ==
+  dependencies:
+    "@babel/helper-module-imports" "^7.0.0"
+    "@babel/traverse" "^7.4.5"
+    "@emotion/is-prop-valid" "^0.8.8"
+    "@emotion/stylis" "^0.8.4"
+    "@emotion/unitless" "^0.7.4"
+    babel-plugin-styled-components ">= 1"
+    css-to-react-native "^3.0.0"
+    hoist-non-react-statics "^3.0.0"
+    shallowequal "^1.1.0"
+    supports-color "^5.5.0"
+
+styled-reset@^4.3.3:
+  version "4.3.3"
+  resolved "https://registry.yarnpkg.com/styled-reset/-/styled-reset-4.3.3.tgz#51b55fceceaa3569068a48cfce44f717910fea7a"
+  integrity sha512-vjkkcWi1I7Nl7xS80+JO96egcUe1v/8r6nv76GnHNs43IjeoBH30W8jsqwFPZ54Ifv3Ncy9ux1cuwBax0yCfVA==
+
 stylehacks@^4.0.0:
   version "4.0.3"
   resolved "https://registry.yarnpkg.com/stylehacks/-/stylehacks-4.0.3.tgz#6718fcaf4d1e07d8a1318690881e8d96726a71d5"
@@ -10423,7 +10596,7 @@ stylehacks@^4.0.0:
     postcss "^7.0.0"
     postcss-selector-parser "^3.0.0"
 
-supports-color@^5.3.0:
+supports-color@^5.3.0, supports-color@^5.5.0:
   version "5.5.0"
   resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"
   integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==