├── .gitignore
├── public
├── _redirects
├── robots.txt
├── favicon.ico
├── logo192.png
├── logo512.png
├── space_kitty_pattern.png
├── manifest.json
└── index.html
├── src
├── assets
│ ├── cat_logo.png
│ ├── cat_logo@2x.png
│ ├── space_cat_logo.png
│ ├── space_cat_logo@2x.png
│ └── space_kitty_pattern.svg
├── components
│ ├── index.js
│ ├── content-section.js
│ ├── __tests__
│ │ ├── query-result.js
│ │ ├── track-detail.js
│ │ ├── module-detail.js
│ │ └── modules-navigation.js
│ ├── md-content.js
│ ├── footer.js
│ ├── query-result.js
│ ├── layout.js
│ ├── module-detail.js
│ ├── header.js
│ ├── modules-navigation.js
│ └── track-detail.js
├── pages
│ ├── index.js
│ ├── tracks.js
│ ├── track.js
│ ├── module.js
│ └── __tests__
│ │ └── tracks.js
├── index.js
├── utils
│ ├── helpers.js
│ ├── useWindowDimensions.js
│ └── test-utils.js
├── containers
│ ├── __tests__
│ │ └── track-card.js
│ └── track-card.js
└── styles.js
├── README.md
└── package.json
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .DS_Store
3 | .env
--------------------------------------------------------------------------------
/public/_redirects:
--------------------------------------------------------------------------------
1 | /* /index.html 200
2 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NansD/odyssey-lift-off-part5-client/main/public/favicon.ico
--------------------------------------------------------------------------------
/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NansD/odyssey-lift-off-part5-client/main/public/logo192.png
--------------------------------------------------------------------------------
/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NansD/odyssey-lift-off-part5-client/main/public/logo512.png
--------------------------------------------------------------------------------
/src/assets/cat_logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NansD/odyssey-lift-off-part5-client/main/src/assets/cat_logo.png
--------------------------------------------------------------------------------
/src/assets/cat_logo@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NansD/odyssey-lift-off-part5-client/main/src/assets/cat_logo@2x.png
--------------------------------------------------------------------------------
/public/space_kitty_pattern.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NansD/odyssey-lift-off-part5-client/main/public/space_kitty_pattern.png
--------------------------------------------------------------------------------
/src/assets/space_cat_logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NansD/odyssey-lift-off-part5-client/main/src/assets/space_cat_logo.png
--------------------------------------------------------------------------------
/src/assets/space_cat_logo@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NansD/odyssey-lift-off-part5-client/main/src/assets/space_cat_logo@2x.png
--------------------------------------------------------------------------------
/src/components/index.js:
--------------------------------------------------------------------------------
1 | export { default as Footer } from './footer';
2 | export { default as Header } from './header';
3 | export { default as Layout } from './layout';
4 | export { default as ModuleDetail } from './module-detail';
5 | export { default as QueryResult } from './query-result';
6 |
--------------------------------------------------------------------------------
/src/pages/index.js:
--------------------------------------------------------------------------------
1 | import React, { Fragment } from 'react';
2 | import { Router } from '@reach/router';
3 | /** importing our pages */
4 | import Tracks from './tracks';
5 | import Track from './track';
6 | import Module from './module';
7 |
8 | export default function Pages() {
9 | return (
10 |
11 |
12 |
13 |
14 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import GlobalStyles from './styles';
4 | import Pages from './pages';
5 | import { ApolloProvider, ApolloClient, InMemoryCache } from '@apollo/client';
6 |
7 | const client = new ApolloClient({
8 | uri: 'http://localhost:4000',
9 | cache: new InMemoryCache(),
10 | });
11 |
12 | ReactDOM.render(
13 |
14 |
15 |
16 | ,
17 | document.getElementById('root')
18 | );
19 |
--------------------------------------------------------------------------------
/src/utils/helpers.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Format seconds to human readable text in a compact form:
3 | * s, m or H:m (not m:s or H:m:s)
4 | */
5 | export const humanReadableTimeFromSeconds = (seconds) => {
6 | if (seconds < 60) {
7 | return `${seconds}s`;
8 | }
9 | const totalMinutes = Math.floor(seconds / 60);
10 | let hours = Math.floor(totalMinutes / 60) || 0;
11 | const minutestoDisplay = totalMinutes % 60;
12 | let timeStr = ``;
13 | if (hours > 0) {
14 | timeStr += `${hours}h `;
15 | }
16 | timeStr += `${minutestoDisplay}m`;
17 |
18 | return timeStr;
19 | };
20 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#000000",
24 | "background_color": "#ffffff"
25 | }
26 |
--------------------------------------------------------------------------------
/src/components/content-section.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from '@emotion/styled';
3 | import { widths, colors } from '../styles';
4 |
5 | /**
6 | * Content Section component renders content (mainly text/mdown based)
7 | * for track and module details
8 | */
9 | const ContentSection = ({ children }) => {
10 | return {children};
11 | };
12 |
13 | export default ContentSection;
14 |
15 | /** ContentSection styled component */
16 | const ContentDiv = styled.div({
17 | marginTop: 10,
18 | display: 'flex',
19 | flexDirection: 'column',
20 | maxWidth: widths.textPageWidth,
21 | width: '100%',
22 | alignSelf: 'center',
23 | backgroundColor: colors.background,
24 | });
25 |
--------------------------------------------------------------------------------
/src/utils/useWindowDimensions.js:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 |
3 | function getWindowDimensions() {
4 | const { innerWidth: width, innerHeight: height } = window;
5 | return {
6 | width,
7 | height,
8 | };
9 | }
10 |
11 | export default function useWindowDimensions() {
12 | const [windowDimensions, setWindowDimensions] = useState(
13 | getWindowDimensions()
14 | );
15 |
16 | useEffect(() => {
17 | function handleResize() {
18 | setWindowDimensions(getWindowDimensions());
19 | }
20 |
21 | window.addEventListener('resize', handleResize);
22 | return () => window.removeEventListener('resize', handleResize);
23 | }, []);
24 |
25 | return windowDimensions;
26 | }
27 |
--------------------------------------------------------------------------------
/src/utils/test-utils.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render } from '@testing-library/react';
3 | import '@testing-library/jest-dom/extend-expect';
4 | import { MockedProvider } from '@apollo/client/testing';
5 |
6 | const renderApollo = (
7 | node,
8 | { mocks, addTypename, defaultOptions, cache, resolvers, ...options }
9 | ) => {
10 | return render(
11 |
19 | {node}
20 | ,
21 | options
22 | );
23 | };
24 |
25 | export * from '@testing-library/react';
26 | export { renderApollo };
27 |
--------------------------------------------------------------------------------
/src/components/__tests__/query-result.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, cleanup } from '../../utils/test-utils';
3 | import QueryResult from '../query-result';
4 |
5 | describe('Query Result', () => {
6 | // automatically unmount and cleanup DOM after the test is finished.
7 | afterEach(cleanup);
8 |
9 | it('renders loading state', async () => {
10 | const { getByTestId } = render();
11 | getByTestId(/spinner/i);
12 | });
13 |
14 | it('renders No Data message', async () => {
15 | // passing no error and no data
16 | const { getByText } = render();
17 | getByText(/nothing to show/i);
18 | });
19 |
20 | it('renders Error', async () => {
21 | const { getByText } = render();
22 | getByText(/you lose/i);
23 | });
24 | });
25 |
--------------------------------------------------------------------------------
/src/components/md-content.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from '@emotion/styled';
3 | import { colors } from '../styles';
4 | import ReactMarkdown from 'react-markdown';
5 |
6 | /**
7 | * Markdown component is a simple style wrapper for markdown content used across our app
8 | */
9 | const MarkDown = ({ content }) => {
10 | return ;
11 | };
12 |
13 | export default MarkDown;
14 |
15 | /** Markdown styled components */
16 | const StyledMarkdown = styled(ReactMarkdown)({
17 | color: colors.grey.darker,
18 |
19 | h1: {
20 | fontSize: '1.7em',
21 | },
22 | h2: {
23 | fontSize: '1.4em',
24 | },
25 | h3: {
26 | fontSize: '1.2em',
27 | },
28 | a: {
29 | color: colors.pink.base,
30 | },
31 | pre: {
32 | padding: 20,
33 | borderRadius: 4,
34 | border: `solid 1px ${colors.silver.dark}`,
35 | backgroundColor: colors.silver.base,
36 | code: {
37 | fontSize: '0.9em',
38 | },
39 | },
40 | });
41 |
--------------------------------------------------------------------------------
/src/components/footer.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from '@emotion/styled';
3 | import { colors, ApolloIcon } from '../styles';
4 |
5 | /**
6 | * Footer is useless component to make our app look a little closer to a real website!
7 | */
8 | const Footer = ({ children }) => {
9 | return (
10 |
11 | 2021 ©{' '}
12 |
13 |
14 |
15 |
16 | );
17 | };
18 |
19 | export default Footer;
20 |
21 | /** Footer styled components */
22 | const FooterContainer = styled.div({
23 | display: 'flex',
24 | flexDirection: 'row',
25 | justifyContent: 'center',
26 | alignItems: 'center',
27 | color: colors.pink.base,
28 | marginTop: 30,
29 | height: 200,
30 | padding: 20,
31 | backgroundColor: 'white',
32 | borderTop: `solid 1px ${colors.pink.light}`,
33 | });
34 |
35 | const LogoContainer = styled.div({
36 | height: 40,
37 | marginLeft: 5,
38 | svg: {
39 | height: 40,
40 | },
41 | });
42 |
--------------------------------------------------------------------------------
/src/components/query-result.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from '@emotion/styled';
3 | import { LoadingSpinner } from '@apollo/space-kit/Loaders/LoadingSpinner';
4 |
5 | /**
6 | * Query Results conditionally renders Apollo useQuery hooks states:
7 | * loading, error or its children when data is ready
8 | */
9 | const QueryResult = ({ loading, error, data, children }) => {
10 | if (error) {
11 | return
ERROR: {error.message}
;
12 | }
13 | if (loading) {
14 | return (
15 |
16 |
17 |
18 | );
19 | }
20 | if (!data) {
21 | return Nothing to show...
;
22 | }
23 | if (data) {
24 | return children;
25 | }
26 | };
27 |
28 | export default QueryResult;
29 |
30 | /** Query Result styled components */
31 | const SpinnerContainer = styled.div({
32 | display: 'flex',
33 | justifyContent: 'center',
34 | alignItems: 'center',
35 | width: '100%',
36 | height: '100vh',
37 | });
38 |
--------------------------------------------------------------------------------
/src/components/layout.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Header, Footer } from '../components';
3 | import styled from '@emotion/styled';
4 | import { widths, unit } from '../styles';
5 |
6 | /**
7 | * Layout renders the full page content:
8 | * with header, Page container and footer
9 | */
10 | const Layout = ({ fullWidth, children, grid }) => {
11 | return (
12 | <>
13 |
14 |
15 | {children}
16 |
17 |
18 | >
19 | );
20 | };
21 |
22 | export default Layout;
23 |
24 | /** Layout styled components */
25 | const PageContainer = styled.div((props) => ({
26 | display: 'flex',
27 | justifyContent: props.grid ? 'center' : 'top',
28 | flexDirection: props.grid ? 'row' : 'column',
29 | flexWrap: 'wrap',
30 | alignSelf: 'center',
31 | flexGrow: 1,
32 | maxWidth: props.fullWidth ? null : `${widths.regularPageWidth}px`,
33 | width: '100%',
34 | padding: props.fullWidth ? 0 : unit * 2,
35 | paddingBottom: unit * 5,
36 | }));
37 |
--------------------------------------------------------------------------------
/src/pages/tracks.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useQuery, gql } from '@apollo/client';
3 | import TrackCard from '../containers/track-card';
4 | import { Layout, QueryResult } from '../components';
5 |
6 | /** TRACKS gql query to retreive all tracks */
7 | export const TRACKS = gql`
8 | query getTracks {
9 | tracksForHome {
10 | id
11 | title
12 | thumbnail
13 | length
14 | modulesCount
15 | author {
16 | name
17 | photo
18 | }
19 | }
20 | }
21 | `;
22 |
23 | /**
24 | * Tracks Page is the Catstronauts home page.
25 | * We display a grid of tracks fetched with useQuery with the TRACKS query
26 | */
27 | const Tracks = () => {
28 | const { loading, error, data } = useQuery(TRACKS);
29 |
30 | return (
31 |
32 |
33 | {data?.tracksForHome?.map((track, index) => (
34 |
35 | ))}
36 |
37 |
38 | );
39 | };
40 |
41 | export default Tracks;
42 |
--------------------------------------------------------------------------------
/src/containers/__tests__/track-card.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { renderApollo, cleanup, waitForElement } from '../../utils/test-utils';
3 | import TrackCard from '../track-card';
4 |
5 | const mockTrackCardData = {
6 | id: 'c_0',
7 | title: 'Cat-stronomy, an introduction',
8 | thumbnail:
9 | 'https://res.cloudinary.com/dety84pbu/image/upload/v1598465568/nebula_cat_djkt9r.jpg',
10 | length: 2377,
11 | author: {
12 | name: 'Henri, le Chat Noir',
13 | photo:
14 | 'https://images.unsplash.com/photo-1442291928580-fb5d0856a8f1?ixlib=rb-1.2.1&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=1080&fit=max&ixid=eyJhcHBfaWQiOjExNzA0OH0',
15 | },
16 | };
17 |
18 | describe('Track Card', () => {
19 | // automatically unmount and cleanup DOM after the test is finished.
20 | afterEach(cleanup);
21 |
22 | it('renders track Card', async () => {
23 | const mocks = [];
24 | const { getByText } = await renderApollo(
25 | ,
26 | {
27 | mocks,
28 | resolvers: {},
29 | addTypename: false,
30 | }
31 | );
32 | await waitForElement(() => getByText(/cat-stronomy/i));
33 | });
34 | });
35 |
--------------------------------------------------------------------------------
/src/components/__tests__/track-detail.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, cleanup } from '../../utils/test-utils';
3 | import TrackDetail from '../track-detail';
4 |
5 | const mockTrack = {
6 | track: {
7 | id: 'c_0',
8 | title: 'Cat-stronomy, an introduction',
9 | description: '# Pulchra vehi vidit misera sola armenta secabatur\n\n',
10 | thumbnail:
11 | 'https://res.cloudinary.com/dety84pbu/image/upload/v1598465568/nebula_cat_djkt9r.jpg',
12 | length: 2377,
13 | modulesCount: 10,
14 | numberOfViews: 51,
15 | author: {
16 | name: 'Henri, le Chat Noir',
17 | photo:
18 | 'https://images.unsplash.com/photo-1442291928580-fb5d0856a8f1?ixlib=rb-1.2.1&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=1080&fit=max&ixid=eyJhcHBfaWQiOjExNzA0OH0',
19 | },
20 | modules: [
21 | {
22 | id: 'l_0',
23 | title: 'Exploring Time and Space',
24 | length: 258,
25 | },
26 | ],
27 | },
28 | };
29 |
30 | describe('Module Detail View', () => {
31 | // automatically unmount and cleanup DOM after the test is finished.
32 | afterEach(cleanup);
33 |
34 | it('renders without error', () => {
35 | render();
36 | });
37 | });
38 |
--------------------------------------------------------------------------------
/src/pages/track.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useQuery, gql } from '@apollo/client';
3 | import { Layout, QueryResult } from '../components';
4 | import TrackDetail from '../components/track-detail';
5 |
6 | /** GET_TRACK gql query to retrieve a specific track by its ID */
7 | export const GET_TRACK = gql`
8 | query getTrack($trackId: ID!) {
9 | track(id: $trackId) {
10 | id
11 | title
12 | author {
13 | id
14 | name
15 | photo
16 | }
17 | thumbnail
18 | length
19 | modulesCount
20 | numberOfViews
21 | modules {
22 | id
23 | title
24 | length
25 | }
26 | description
27 | }
28 | }
29 | `;
30 |
31 | /**
32 | * Track Page fetches a track's data from the gql query GET_TRACK
33 | * and provides it to the TrackDetail component to display
34 | */
35 | const Track = ({ trackId }) => {
36 | const { loading, error, data } = useQuery(GET_TRACK, {
37 | variables: { trackId },
38 | });
39 |
40 | return (
41 |
42 |
43 |
44 |
45 |
46 | );
47 | };
48 |
49 | export default Track;
50 |
--------------------------------------------------------------------------------
/src/pages/module.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useQuery, gql } from '@apollo/client';
3 | import { Layout, ModuleDetail, QueryResult } from '../components';
4 |
5 | /**
6 | * GET_MODULE_AND_PARENT_TRACK gql query to retrieve a specific module and its parent track,
7 | * both needed for the ModuleDetail component
8 | */
9 | export const GET_MODULE_AND_PARENT_TRACK = gql`
10 | query getModuleAndParentTrack($moduleId: ID!, $trackId: ID!) {
11 | module(id: $moduleId) {
12 | id
13 | title
14 | content
15 | videoUrl
16 | }
17 | track(id: $trackId) {
18 | id
19 | title
20 | modules {
21 | id
22 | title
23 | length
24 | }
25 | }
26 | }
27 | `;
28 |
29 | /**
30 | * Module page fetches both parent track and module's data from the gql query GET_MODULE_AND_PARENT_TRACK
31 | * and feeds them to the ModuleDetail component
32 | */
33 | const Module = ({ moduleId, trackId }) => {
34 | const { loading, error, data } = useQuery(GET_MODULE_AND_PARENT_TRACK, {
35 | variables: { moduleId, trackId },
36 | });
37 |
38 | return (
39 |
40 |
41 |
42 |
43 |
44 | );
45 | };
46 |
47 | export default Module;
48 |
--------------------------------------------------------------------------------
/src/components/__tests__/module-detail.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, cleanup } from '../../utils/test-utils';
3 | import ModuleDetail from '../module-detail';
4 |
5 | const mockModule = {
6 | id: 'l_1',
7 | title: 'The Night Sky',
8 | content:
9 | '# Et tempus voces tigride remisso fer coimus\n\n## Montibus arbusta detrectas haud\n\n',
10 | thumbnail: null,
11 | videoUrl: 'https://youtu.be/dlKzlksOUtU',
12 | topic: 'Cat-stronomy',
13 | length: 164,
14 | };
15 |
16 | const mockParentTrack = {
17 | id: 'c_0',
18 | title: 'Cat-stronomy, an introduction',
19 | description: '# Pulchra vehi vidit misera sola armenta secabatur\n\n',
20 | thumbnail:
21 | 'https://res.cloudinary.com/dety84pbu/image/upload/v1598465568/nebula_cat_djkt9r.jpg',
22 | trackLength: 2377,
23 | modulesCount: 10,
24 | numberOfViews: 51,
25 | author: {
26 | name: 'Henri, le Chat Noir',
27 | photo:
28 | 'https://images.unsplash.com/photo-1442291928580-fb5d0856a8f1?ixlib=rb-1.2.1&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=1080&fit=max&ixid=eyJhcHBfaWQiOjExNzA0OH0',
29 | },
30 | modules: [
31 | {
32 | id: 'l_0',
33 | title: 'Exploring Time and Space',
34 | length: 258,
35 | },
36 | ],
37 | };
38 |
39 | describe('Module Detail View', () => {
40 | // automatically unmount and cleanup DOM after the test is finished.
41 | afterEach(cleanup);
42 |
43 | it('renders without error', () => {
44 | render();
45 | });
46 | });
47 |
--------------------------------------------------------------------------------
/src/components/__tests__/modules-navigation.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, cleanup } from '../../utils/test-utils';
3 | import ModuleNav from '../modules-navigation';
4 |
5 | const mockModule = {
6 | id: 'l_1',
7 | title: 'The Night Sky',
8 | content:
9 | '# Et tempus voces tigride remisso fer coimus\n\n## Montibus arbusta detrectas haud\n\n',
10 | thumbnail: null,
11 | videoUrl: 'https://youtu.be/dlKzlksOUtU',
12 | topic: 'Cat-stronomy',
13 | length: 164,
14 | };
15 |
16 | const mockParentTrack = {
17 | id: 'c_0',
18 | title: 'Cat-stronomy, an introduction',
19 | description: '# Pulchra vehi vidit misera sola armenta secabatur\n\n',
20 | thumbnail:
21 | 'https://res.cloudinary.com/dety84pbu/image/upload/v1598465568/nebula_cat_djkt9r.jpg',
22 | trackLength: 2377,
23 | modulesCount: 10,
24 | numberOfViews: 51,
25 | author: {
26 | name: 'Henri, le Chat Noir',
27 | photo:
28 | 'https://images.unsplash.com/photo-1442291928580-fb5d0856a8f1?ixlib=rb-1.2.1&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=1080&fit=max&ixid=eyJhcHBfaWQiOjExNzA0OH0',
29 | },
30 | modules: [
31 | {
32 | id: 'l_0',
33 | title: 'Exploring Time and Space',
34 | length: 258,
35 | },
36 | ],
37 | };
38 |
39 | describe('Modules Navigation View', () => {
40 | // automatically unmount and cleanup DOM after the test is finished.
41 | afterEach(cleanup);
42 |
43 | it('renders without error', () => {
44 | render();
45 | });
46 | });
47 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Odyssey Lift-off V (Client): Road to production
2 |
3 | Welcome to the companion app of Odyssey Lift-off V (client)! You can [find the course lessons and instructions on Odyssey](https://odyssey.apollographql.com/lift-off-part5), Apollo's learning platform.
4 |
5 | You can [preview the completed demo app here](https://lift-off-client-demo.netlify.app/).
6 |
7 | You can [find the server counterpart here](https://github.com/apollographql/odyssey-lift-off-part5-server).
8 |
9 | ## How to use this repo
10 |
11 | The course will walk you step by step on how to implement the features you see in the demo app. This codebase is the starting point of your journey!
12 |
13 | This repo is the starting point of our React client application.
14 |
15 | To get started:
16 |
17 | 1. Run `npm install`.
18 | 1. Run `npm start`.
19 |
20 | This will start the React application and open up `localhost:3000` in your web browser.
21 |
22 | > Note that the client will show an `ERROR: Failed to fetch` message unless you have the [counterpart GraphQL server](https://github.com/apollographql/odyssey-lift-off-part5-server) running locally on `localhost:4000`.
23 |
24 | To check the **final** stage of the client, with all of the steps and code completed, checkout the `final` branch by running the following command in your terminal:
25 |
26 | ```bash
27 | git checkout final
28 | ```
29 |
30 | ## Getting Help
31 |
32 | For any issues or problems concerning the course content, please refer to the [Odyssey topic in our community forums](https://community.apollographql.com/tags/c/help/6/odyssey).
33 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "catstronauts-client-complete",
3 | "version": "1.0.0",
4 | "private": true,
5 | "description": "front-end demo app for Apollo's lift-off IV course",
6 | "dependencies": {
7 | "@apollo/client": "^3.3.6",
8 | "@apollo/space-kit": "^9.3.1",
9 | "@emotion/cache": "^11.4.0",
10 | "@emotion/core": "^10.1.1",
11 | "@emotion/react": "^11.4.0",
12 | "@emotion/styled": "^11.3.0",
13 | "@reach/router": "^1.3.4",
14 | "framer-motion": "^4.1.17",
15 | "graphql": "^15.3.0",
16 | "react": "^16.13.1",
17 | "react-dom": "^16.13.1",
18 | "react-emotion": "^10.0.0",
19 | "react-markdown": "^6.0.2",
20 | "react-player": "^2.6.0",
21 | "react-scripts": "^4.0.3"
22 | },
23 | "scripts": {
24 | "start": "react-scripts start",
25 | "build": "react-scripts build",
26 | "test": "react-scripts test",
27 | "eject": "react-scripts eject"
28 | },
29 | "eslintConfig": {
30 | "extends": "react-app"
31 | },
32 | "browserslist": {
33 | "production": [
34 | ">0.2%",
35 | "not dead",
36 | "not op_mini all"
37 | ],
38 | "development": [
39 | "last 1 chrome version",
40 | "last 1 firefox version",
41 | "last 1 safari version"
42 | ]
43 | },
44 | "devDependencies": {
45 | "@testing-library/jest-dom": "^4.2.4",
46 | "@testing-library/react": "^9.3.2",
47 | "@testing-library/user-event": "^7.1.2",
48 | "apollo": "^2.30.2"
49 | },
50 | "main": "src/index.js",
51 | "author": "Raphael Terrier @R4ph-t",
52 | "license": "MIT"
53 | }
54 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
14 |
15 |
24 | Catstronauts
25 |
26 |
27 |
28 |
29 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/src/components/module-detail.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from '@emotion/styled';
3 | import { colors, widths } from '../styles';
4 | import useWindowDimensions from '../utils/useWindowDimensions';
5 | import ContentSection from './content-section';
6 | import ReactPlayer from 'react-player/youtube';
7 | import ModulesNav from './modules-navigation';
8 | import MarkDown from './md-content';
9 |
10 | /**
11 | * Module Detail renders content of a given module:
12 | * Video player, modules navigation and markdown content
13 | */
14 | const ModuleDetail = ({ track, module }) => {
15 | const { videoUrl, title, content } = module;
16 | const { width } = useWindowDimensions();
17 |
18 | return (
19 | <>
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | {title}
30 |
31 |
32 | >
33 | );
34 | };
35 |
36 | export default ModuleDetail;
37 |
38 | /** Module Detail styled components */
39 | const TopSection = styled.div({
40 | display: 'flex',
41 | justifyContent: 'center',
42 | backgroundColor: colors.black.base,
43 | padding: 20,
44 | borderBottom: `solid 1px ${colors.pink.base}`,
45 | });
46 |
47 | const TopContainer = styled.div(({ totalWidth }) => ({
48 | display: 'flex',
49 | flexDirection: 'row',
50 | alignSelf: 'center',
51 | width: '100%',
52 | maxWidth: widths.largePageWidth,
53 | // 60 below removes 3 * 20 horizontal paddings (sides and inner between player and list)
54 | height: ((totalWidth - 60) * (2 / 3)) / (16 / 9),
55 | maxHeight: (widths.largePageWidth * (2 / 3)) / (16 / 9),
56 | }));
57 |
58 | const PlayerContainer = styled.div({
59 | width: '66%',
60 | });
61 |
62 | const ModuleTitle = styled.h1({
63 | marginTop: 10,
64 | marginBottom: 30,
65 | paddingBottom: 10,
66 | color: colors.black.lighter,
67 | borderBottom: `solid 1px ${colors.pink.base}`,
68 | });
69 |
--------------------------------------------------------------------------------
/src/components/header.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { colors, widths } from '../styles';
3 | import styled from '@emotion/styled';
4 | import { Link } from '@reach/router';
5 | import logo from '../assets/space_cat_logo.png';
6 |
7 | /**
8 | * Header renders the top navigation
9 | * for this particular tutorial level, it only holds the home button
10 | */
11 | const Header = ({ children }) => {
12 | return (
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | Catstronaut
23 | Kitty space academy
24 |
25 |
26 |
27 |
28 | {children}
29 |
30 |
31 | );
32 | };
33 |
34 | export default Header;
35 |
36 | /** Header styled components */
37 | const HeaderBar = styled.div({
38 | display: 'flex',
39 | flexDirection: 'row',
40 | alignItems: 'center',
41 | justifyContent: 'center',
42 | borderBottom: `solid 1px ${colors.pink.light}`,
43 | boxShadow: '0px 1px 5px 0px rgba(0,0,0,0.15)',
44 | padding: '5px 30px',
45 | minHeight: 80,
46 | backgroundColor: 'white',
47 | });
48 |
49 | const Container = styled.div({
50 | width: `${widths.regularPageWidth}px`,
51 | });
52 |
53 | const HomeLink = styled(Link)({
54 | textDecoration: 'none',
55 | });
56 |
57 | const HomeButtonContainer = styled.div({
58 | display: 'flex',
59 | flex: 1,
60 | });
61 |
62 | const HomeButton = styled.div({
63 | display: 'flex',
64 | flexDirection: 'row',
65 | color: colors.accent,
66 | alignItems: 'center',
67 | ':hover': {
68 | color: colors.pink.dark,
69 | },
70 | });
71 |
72 | const LogoContainer = styled.div({ display: 'flex', alignSelf: 'center' });
73 |
74 | const Logo = styled.img({
75 | height: 60,
76 | width: 60,
77 | marginRight: 8,
78 | });
79 |
80 | const Title = styled.div({
81 | display: 'flex',
82 | flexDirection: 'column',
83 | h3: {
84 | lineHeight: '1em',
85 | marginBottom: 0,
86 | },
87 | div: {
88 | fontSize: '0.9em',
89 | lineHeight: '0.8em',
90 | paddingLeft: 2,
91 | },
92 | });
93 |
--------------------------------------------------------------------------------
/src/styles.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import '@apollo/space-kit/reset.css';
3 | import { colors as SKColors } from '@apollo/space-kit/colors';
4 | import { Global } from '@emotion/core';
5 |
6 | const breakpoints = [480, 768, 992, 1200];
7 | export const mq = breakpoints.map((bp) => `@media (min-width: ${bp}px)`);
8 |
9 | export const unit = 8;
10 | export const widths = {
11 | largePageWidth: 1600,
12 | regularPageWidth: 1100,
13 | textPageWidth: 800,
14 | };
15 | export const colors = {
16 | primary: SKColors.indigo.base,
17 | secondary: SKColors.teal.base,
18 | accent: SKColors.pink.base,
19 | background: SKColors.silver.light,
20 | grey: SKColors.silver.dark,
21 | text: SKColors.black.base,
22 | textSecondary: SKColors.grey.dark,
23 | ...SKColors,
24 | };
25 |
26 | const GlobalStyles = () => (
27 |
69 | );
70 |
71 | export default GlobalStyles;
72 |
73 | export { IconRun } from '@apollo/space-kit/icons/IconRun';
74 | export { IconView } from '@apollo/space-kit/icons/IconView';
75 | export { IconTime } from '@apollo/space-kit/icons/IconTime';
76 | export { IconBook } from '@apollo/space-kit/icons/IconBook';
77 | export { IconYoutube } from '@apollo/space-kit/icons/IconYoutube';
78 | export { IconArrowRight } from '@apollo/space-kit/icons/IconArrowRight';
79 | export { IconDoubleArrowRight } from '@apollo/space-kit/icons/IconDoubleArrowRight';
80 | export { ApolloIcon } from '@apollo/space-kit/icons/ApolloIcon';
81 | export { Button } from '@apollo/space-kit/Button';
82 |
--------------------------------------------------------------------------------
/src/pages/__tests__/tracks.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | // this adds custom jest matchers from jest-dom
3 | import '@testing-library/jest-dom/extend-expect';
4 | import { InMemoryCache } from '@apollo/client';
5 | import { renderApollo, cleanup, waitForElement } from '../../utils/test-utils';
6 | import Tracks, { TRACKS } from '../tracks';
7 |
8 | const mockTrack = {
9 | id: 'c_0',
10 | title: 'Nap, the hard way',
11 | thumbnail:
12 | 'https://images.unsplash.com/photo-1542403810-74c578300013?ixlib=rb-1.2.1&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=1080&fit=max&ixid=eyJhcHBfaWQiOjExNzA0OH0',
13 | length: 1420,
14 | modulesCount: 6,
15 | author: {
16 | name: 'Cheshire Cat',
17 | photo:
18 | 'https://images.unsplash.com/photo-1593627010886-d34828365da7?ixlib=rb-1.2.1&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=1080&fit=max&ixid=eyJhcHBfaWQiOjExNzA0OH0',
19 | },
20 | };
21 |
22 | describe('Tracks Page', () => {
23 | afterEach(cleanup);
24 | const cache = new InMemoryCache({ addTypename: false });
25 |
26 | it('renders tracks', async () => {
27 | const mocks = [
28 | {
29 | request: { query: TRACKS },
30 | result: {
31 | data: {
32 | tracksForHome: [mockTrack],
33 | },
34 | },
35 | },
36 | ];
37 |
38 | const { getByText } = await renderApollo(, {
39 | mocks,
40 | cache,
41 | });
42 |
43 | await waitForElement(() => getByText(/nap, the hard way/i));
44 | });
45 | });
46 |
47 | /*
48 | import React from 'react';
49 | import { renderApollo, cleanup, waitForElement } from '../../test-utils';
50 | import { InMemoryCache } from '@apollo/client';
51 |
52 | import Tracks, { TRACKS } from '../tracks';
53 | const mockTrack = {
54 | id: 'c_0',
55 | title: 'Nap, the hard way',
56 | thumbnail:
57 | 'https://images.unsplash.com/photo-1542403810-74c578300013?ixlib=rb-1.2.1&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=1080&fit=max&ixid=eyJhcHBfaWQiOjExNzA0OH0',
58 | trackLength: 1420,
59 | author: {
60 | name: 'Cheshire Cat',
61 | },
62 | };
63 |
64 | describe('Tracks Page', () => {
65 | // automatically unmount and cleanup DOM after the test is finished.
66 | afterEach(cleanup);
67 |
68 | it('renders tracks', async () => {
69 | const cache = new InMemoryCache({ addTypename: false });
70 | const mocks = [
71 | {
72 | request: { query: TRACKS },
73 | result: {
74 | data: {
75 | tracks: [mockTrack],
76 | },
77 | },
78 | },
79 | ];
80 | const { getByText } = await renderApollo(, {
81 | mocks,
82 | cache,
83 | });
84 | await waitForElement(() => getByText(/nap, the hard way/i));
85 | });
86 | });*/
87 |
--------------------------------------------------------------------------------
/src/components/modules-navigation.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from '@emotion/styled';
3 | import { Link } from '@reach/router';
4 | import { colors, IconArrowRight, IconDoubleArrowRight } from '../styles';
5 | import { humanReadableTimeFromSeconds } from '../utils/helpers';
6 |
7 | /**
8 | * Module Navigation: displays a list of modules titles
9 | * from a track and navigates to the modules page
10 | */
11 | const ModulesNav = ({ module, track }) => {
12 | return (
13 |
14 |
15 |
16 | {track.title}
17 |
18 |
19 |
20 | {track.modules.map((navModule) => (
21 |
22 |
23 |
24 |
25 | {navModule.id === module.id ? (
26 |
27 | ) : (
28 |
29 | )}
30 | {navModule.title}
31 | {humanReadableTimeFromSeconds(navModule.length)}
32 |
33 |
34 |
35 |
36 | ))}
37 |
38 |
39 | );
40 | };
41 |
42 | export default ModulesNav;
43 |
44 | /** Module Navigation styled components */
45 | const ModulesNavContainer = styled.div({
46 | width: '33%',
47 | position: 'relative',
48 | marginLeft: 20,
49 | backgroundColor: colors.black.light,
50 | borderRadius: 4,
51 | border: `solid 1px ${colors.black.lighter}`,
52 | overflow: 'auto',
53 | });
54 |
55 | const trackTitleHeight = 70;
56 |
57 | const ModuleTitle = styled.div({
58 | display: 'flex',
59 | position: 'sticky',
60 | fontSize: '1.6em',
61 | fontWeight: '400',
62 | height: trackTitleHeight,
63 | alignItems: 'center',
64 | justifyContent: 'center',
65 | textAlign: 'center',
66 | backgroundColor: 'colors.pink.base',
67 | borderBottom: `solid 1px ${colors.pink.base}`,
68 |
69 | a: {
70 | textDecoration: 'none',
71 | color: colors.silver.base,
72 | },
73 | ':hover': {
74 | backgroundColor: colors.black.base,
75 | },
76 | });
77 |
78 | const ModulesList = styled.ul({
79 | listStyle: 'none',
80 | margin: 0,
81 | padding: 0,
82 | overflowY: 'scroll',
83 | height: `calc(100% - ${trackTitleHeight}px)`,
84 | });
85 |
86 | const ModuleListItem = styled.li((props) => ({
87 | borderBottom: `solid 1px ${colors.grey.darker}`,
88 | ':last-child': {
89 | borderBottom: 'none',
90 | },
91 | }));
92 |
93 | const ModuleNavStyledLink = styled(Link)({
94 | textDecoration: 'none',
95 | display: 'flex',
96 | alignItems: 'center',
97 | });
98 |
99 | const ModuleListItemContent = styled.div((props) => ({
100 | backgroundColor: props.isActive ? colors.black.base : colors.black.light,
101 | color: props.isActive ? colors.silver.lighter : colors.silver.darker,
102 | minHeight: 80,
103 | padding: '10px 20px',
104 | display: 'flex',
105 | alignItems: 'center',
106 | justifyContent: 'space-between',
107 | fontSize: '1.1em',
108 | flex: 1,
109 | ':hover': {
110 | backgroundColor: props.isActive ? colors.black.dark : colors.black.base,
111 | color: 'white',
112 | },
113 | }));
114 |
--------------------------------------------------------------------------------
/src/containers/track-card.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from '@emotion/styled';
3 | import { colors, mq } from '../styles';
4 | import { humanReadableTimeFromSeconds } from '../utils/helpers';
5 | import { Link } from '@reach/router';
6 | import { gql, useMutation } from '@apollo/client';
7 |
8 | /**
9 | * Mutation to increment a track's number of views
10 | * (exported for tests)
11 | */
12 | export const INCREMENT_TRACK_VIEWS = gql`
13 | mutation IncrementTrackViewsMutation($incrementTrackViewsId: ID!) {
14 | incrementTrackViews(id: $incrementTrackViewsId) {
15 | code
16 | success
17 | message
18 | track {
19 | id
20 | numberOfViews
21 | }
22 | }
23 | }
24 | `;
25 |
26 | /**
27 | * Track Card component renders basic info in a card format
28 | * for each track populating the tracks grid homepage.
29 | */
30 | const TrackCard = ({ track }) => {
31 | const { title, thumbnail, author, length, modulesCount, id } = track;
32 |
33 | const [incrementTrackViews] = useMutation(INCREMENT_TRACK_VIEWS, {
34 | variables: { incrementTrackViewsId: id },
35 | // to observe what the mutation response returns
36 | onCompleted: (data) => {
37 | console.log(data);
38 | },
39 | });
40 |
41 | return (
42 |
43 |
44 |
45 |
46 |
47 |
48 | {title || ''}
49 |
50 |
51 |
52 | {author.name}
53 |
54 | {modulesCount} modules - {humanReadableTimeFromSeconds(length)}
55 |
56 |
57 |
58 |
59 |
60 |
61 | );
62 | };
63 |
64 | export default TrackCard;
65 |
66 | /** Track Card styled components */
67 | const CardContainer = styled(Link)({
68 | borderRadius: 6,
69 | color: colors.text,
70 | backgroundSize: 'cover',
71 | backgroundColor: 'white',
72 | boxShadow: '0px 1px 5px 0px rgba(0,0,0,0.15)',
73 | backgroundPosition: 'center',
74 | display: 'flex',
75 | flexDirection: 'column',
76 | justifyContent: 'space-between',
77 | [mq[0]]: {
78 | width: '90%',
79 | },
80 | [mq[1]]: {
81 | width: '47%',
82 | },
83 | [mq[2]]: {
84 | width: '31%',
85 | },
86 | height: 380,
87 | margin: 10,
88 | overflow: 'hidden',
89 | position: 'relative',
90 | ':hover': {
91 | backgroundColor: colors.pink.lightest,
92 | },
93 | cursor: 'pointer',
94 | textDecoration: 'none',
95 | });
96 |
97 | const CardContent = styled.div({
98 | display: 'flex',
99 | flexDirection: 'column',
100 | justifyContent: 'space-around',
101 | height: '100%',
102 | });
103 |
104 | const CardTitle = styled.h3({
105 | textAlign: 'center',
106 | fontSize: '1.4em',
107 | lineHeight: '1em',
108 | fontWeight: 700,
109 | color: colors.text,
110 | flex: 1,
111 | });
112 |
113 | const CardImageContainer = styled.div({
114 | height: 220,
115 | position: 'relative',
116 | '::after': {
117 | content: '""',
118 | position: 'absolute',
119 | top: 0,
120 | bottom: 0,
121 | left: 0,
122 | right: 0,
123 | background: 'rgba(250,0,150,0.20)',
124 | },
125 | });
126 |
127 | const CardImage = styled.img({
128 | objectFit: 'cover',
129 | width: '100%',
130 | height: '100%',
131 | filter: 'grayscale(60%)',
132 | });
133 |
134 | const CardBody = styled.div({
135 | padding: 18,
136 | flex: 1,
137 | display: 'flex',
138 | color: colors.textSecondary,
139 | flexDirection: 'column',
140 | justifyContent: 'space-around',
141 | });
142 |
143 | const CardFooter = styled.div({
144 | display: 'flex',
145 | flexDirection: 'Row',
146 | });
147 |
148 | const AuthorImage = styled.img({
149 | height: 30,
150 | width: 30,
151 | marginRight: 8,
152 | borderRadius: '50%',
153 | objectFit: 'cover',
154 | });
155 |
156 | const AuthorAndTrack = styled.div({
157 | display: 'flex',
158 | flexDirection: 'column',
159 | justifyContent: 'space-between',
160 | });
161 |
162 | const AuthorName = styled.div({
163 | lineHeight: '1em',
164 | fontSize: '1.1em',
165 | });
166 |
167 | const TrackLength = styled.div({
168 | fontSize: '0.8em',
169 | });
170 |
--------------------------------------------------------------------------------
/src/components/track-detail.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from '@emotion/styled';
3 | import {
4 | colors,
5 | Button,
6 | IconRun,
7 | IconView,
8 | IconTime,
9 | IconBook,
10 | } from '../styles';
11 | import { humanReadableTimeFromSeconds } from '../utils/helpers';
12 | import { Link } from '@reach/router';
13 | import ContentSection from './content-section';
14 | import MarkDown from './md-content';
15 |
16 | /**
17 | * Track Detail component renders the main content of a given track:
18 | * author, length, number of views, modules list, among other things.
19 | * It provides access to the first module of the track.
20 | */
21 | const TrackDetail = ({ track }) => {
22 | const {
23 | title,
24 | description,
25 | thumbnail,
26 | author,
27 | length,
28 | modulesCount,
29 | modules,
30 | numberOfViews,
31 | } = track;
32 |
33 | return (
34 |
35 |
36 |
37 |
38 | {title}
39 |
40 |
41 |
42 | Track details
43 |
44 |
45 | {numberOfViews} view(s)
46 |
47 |
48 |
49 | {modulesCount} modules
50 |
51 |
52 |
53 | {humanReadableTimeFromSeconds(length)}
54 |
55 |
56 |
57 | Author
58 |
59 | {author.name}
60 |
61 |
62 |
63 | }
65 | color={colors.pink.base}
66 | size="large"
67 | >
68 | Start Track
69 |
70 |
71 |
72 |
73 |
74 |
75 | Modules
76 |
77 | {modules.map((module) => (
78 | -
79 |
{module.title}
80 |
81 | {humanReadableTimeFromSeconds(module.length)}
82 |
83 |
84 | ))}
85 |
86 |
87 |
88 |
89 |
90 |
91 | );
92 | };
93 |
94 | export default TrackDetail;
95 |
96 | /** Track detail styled components */
97 | const CoverImage = styled.img({
98 | objectFit: 'cover',
99 | maxHeight: 400,
100 | borderRadius: 4,
101 | marginBottom: 30,
102 | });
103 |
104 | const StyledLink = styled(Link)({
105 | textDecoration: 'none',
106 | color: 'white',
107 | });
108 |
109 | const TrackDetails = styled.div({
110 | display: 'flex',
111 | flexDirection: 'column',
112 | alignItems: 'center',
113 | padding: 20,
114 | borderRadius: 4,
115 | marginBottom: 30,
116 | border: `solid 1px ${colors.silver.dark}`,
117 | backgroundColor: colors.silver.lighter,
118 | h1: {
119 | width: '100%',
120 | textAlign: 'center',
121 | marginBottom: 5,
122 | },
123 | h4: {
124 | fontSize: '1.2em',
125 | marginBottom: 5,
126 | color: colors.text,
127 | },
128 | });
129 |
130 | const DetailRow = styled.div({
131 | display: 'flex',
132 | flexDirection: 'row',
133 | justifyContent: 'space-between',
134 | alignItems: 'center',
135 | width: '100%',
136 | paddingBottom: 20,
137 | marginBottom: 20,
138 | borderBottom: `solid 1px ${colors.silver.dark}`,
139 | });
140 |
141 | const DetailItem = styled.div({
142 | display: 'flex',
143 | flexDirection: 'column',
144 | alignItems: 'center',
145 | justifyContent: 'space-between',
146 | color: colors.textSecondary,
147 | alignSelf: 'center',
148 | });
149 |
150 | const AuthorImage = styled.img({
151 | height: 30,
152 | width: 30,
153 | marginBottom: 8,
154 | borderRadius: '50%',
155 | objectFit: 'cover',
156 | });
157 |
158 | const AuthorName = styled.div({
159 | lineHeight: '1em',
160 | fontSize: '1em',
161 | });
162 |
163 | const IconAndLabel = styled.div({
164 | display: 'flex',
165 | flex: 'row',
166 | alignItems: 'center',
167 | maxHeight: 20,
168 | width: '100%',
169 | div: {
170 | marginLeft: 8,
171 | },
172 | svg: {
173 | maxHeight: 16,
174 | },
175 | '#viewCount': {
176 | color: colors.pink.base,
177 | },
178 | });
179 |
180 | const ModuleListContainer = styled.div({
181 | width: '100%',
182 | ul: {
183 | listStyle: 'none',
184 | padding: 0,
185 | margin: 0,
186 | marginTop: 5,
187 | li: {
188 | fontSize: '1em',
189 | display: 'flex',
190 | justifyContent: 'space-between',
191 | paddingBottom: 2,
192 | },
193 | },
194 | });
195 |
196 | const ModuleLength = styled.div({
197 | marginLeft: 30,
198 | color: colors.grey.light,
199 | });
200 |
--------------------------------------------------------------------------------
/src/assets/space_kitty_pattern.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------