Browse Source

changed layout.

Tomi Cvetic 4 years ago
parent
commit
9a1b62032b
35 changed files with 771 additions and 383 deletions
  1. 15 4
      backend/.vscode/settings.json
  2. 2 4
      backend/schema.graphql
  3. 18 16
      backend/src/training/resolvers.ts
  4. 22 32
      backend/src/user/resolvers.ts
  5. 15 4
      frontend/.vscode/settings.json
  6. 9 31
      frontend/pages/_app.tsx
  7. 35 6
      frontend/pages/admin/index.tsx
  8. 113 37
      frontend/pages/index.tsx
  9. 9 4
      frontend/pages/timer/[id].tsx
  10. 0 37
      frontend/pages/user.tsx
  11. 14 0
      frontend/src/app/components/AdminPage.tsx
  12. 22 0
      frontend/src/app/components/AdminSideBar.tsx
  13. 4 5
      frontend/src/app/components/Page.tsx
  14. 40 0
      frontend/src/app/components/Tile.tsx
  15. 30 0
      frontend/src/app/components/TileContainer.tsx
  16. 8 0
      frontend/src/app/index.tsx
  17. 5 15
      frontend/src/form/components/TextInput.tsx
  18. 63 4
      frontend/src/gql/index.tsx
  19. 31 3
      frontend/src/styles/global.ts
  20. 12 23
      frontend/src/timer/utils.ts
  21. 5 5
      frontend/src/training/components/ExerciseComposition.tsx
  22. 6 29
      frontend/src/training/components/Training.tsx
  23. 59 0
      frontend/src/training/components/TrainingArchive.tsx
  24. 3 15
      frontend/src/training/components/TrainingBlock.tsx
  25. 13 0
      frontend/src/training/components/TrainingHint.tsx
  26. 27 31
      frontend/src/training/components/TrainingMeta.tsx
  27. 47 0
      frontend/src/training/components/TrainingProgram.tsx
  28. 0 14
      frontend/src/training/components/trainingArchive.js
  29. 0 8
      frontend/src/training/components/trainingHint.js
  30. 2 1
      frontend/src/training/index.tsx
  31. 24 6
      frontend/src/training/training.graphql
  32. 50 0
      frontend/src/user/components/LoginPage.tsx
  33. 60 40
      frontend/src/user/components/SignupForm.tsx
  34. 5 8
      frontend/src/user/hooks.tsx
  35. 3 1
      frontend/src/user/index.ts

+ 15 - 4
backend/.vscode/settings.json

@@ -1,5 +1,16 @@
 {
-    "prettier.jsxSingleQuote": true,
-    "prettier.semi": false,
-    "prettier.singleQuote": true
-}
+  "prettier.jsxSingleQuote": true,
+  "prettier.semi": false,
+  "prettier.singleQuote": true,
+  "editor.tabSize": 2,
+  "prettier.printWidth": 100,
+  "workbench.colorTheme": "Cobalt2",
+  "workbench.colorCustomizations": {
+    "[Cobalt2]": {
+      "titleBar.activeForeground": "#e0e0e0",
+      "titleBar.activeBackground": "#3d0058",
+      "titleBar.inactiveBackground": "#e0e0e0",
+      "titleBar.inactiveForeground": "#3d0058"
+    }
+  }
+}

+ 2 - 4
backend/schema.graphql

@@ -36,6 +36,7 @@ type Query {
     last: Int
   ): [Training!]!
   publishedTrainings: [Training!]!
+  trainingsCount(where: TrainingWhereInput): TrainingConnection!
   trainingType(where: TrainingTypeWhereUniqueInput!): TrainingType
   trainingTypes(
     where: TrainingTypeWhereInput
@@ -94,10 +95,7 @@ type Mutation {
     published: Boolean!
     blocks: BlockInstanceCreateManyWithoutParentTrainingInput
   ): Training!
-  updateTraining(
-    where: TrainingWhereUniqueInput!
-    data: TrainingUpdateInput!
-  ): Training!
+  updateTraining(where: TrainingWhereUniqueInput!, data: TrainingUpdateInput!): Training!
   createTrainingType(name: String!, description: String!): TrainingType!
   # createBlock(
   #   title: String!

+ 18 - 16
backend/src/training/resolvers.ts

@@ -13,12 +13,20 @@ export const resolvers: IResolvers = {
     },
     trainings: async (parent, args, context, info) => {
       checkPermission(context, ['INSTRUCTOR', 'ADMIN'])
-      return context.db.query.trainings({}, info)
+      return context.db.query.trainings(args, info)
+    },
+    trainingsCount: async (parent, args, context, info) => {
+      checkPermission(context)
+      return await context.db.query.trainingsConnection(args, '{ aggregate { count } }')
     },
     publishedTrainings: async (parent, args, context, info) => {
       checkPermission(context)
       return context.db.query.trainings(
-        { where: { published: true }, orderBy: 'trainingDate_DESC', first: 20 },
+        {
+          where: { published: true },
+          orderBy: 'trainingDate_DESC',
+          first: 20,
+        },
         info
       )
     },
@@ -37,16 +45,13 @@ export const resolvers: IResolvers = {
     exercises: async (parent, args, context, info) => {
       checkPermission(context)
       return context.db.query.exercises({}, info)
-    }
+    },
   },
 
   Mutation: {
     createTraining: async (parent, args, context, info) => {
       checkPermission(context, ['INSTRUCTOR', 'ADMIN'])
-      const training = await context.db.mutation.createTraining(
-        { data: args },
-        info
-      )
+      const training = await context.db.mutation.createTraining({ data: args }, info)
       return training
     },
     updateTraining: async (parent, args, context, info) => {
@@ -56,10 +61,7 @@ export const resolvers: IResolvers = {
     },
     createTrainingType: async (parent, args, context, info) => {
       checkPermission(context, ['INSTRUCTOR', 'ADMIN'])
-      const trainingType = await context.db.mutation.createTrainingType(
-        { data: args },
-        info
-      )
+      const trainingType = await context.db.mutation.createTrainingType({ data: args }, info)
       return trainingType
     },
     // createBlock: async (parent, args, context, info) => {
@@ -76,7 +78,7 @@ export const resolvers: IResolvers = {
       checkPermission(context)
       await context.db.mutation.updateTraining({
         where: { id: args.training },
-        data: { registrations: { connect: { id: context.req.userId } } }
+        data: { registrations: { connect: { id: context.req.userId } } },
       })
       return 'Success!'
     },
@@ -84,7 +86,7 @@ export const resolvers: IResolvers = {
       checkPermission(context)
       await context.db.mutation.updateTraining({
         where: { id: args.training },
-        data: { registrations: { disconnect: { id: context.req.userId } } }
+        data: { registrations: { disconnect: { id: context.req.userId } } },
       })
       return 'Success!'
     },
@@ -92,9 +94,9 @@ export const resolvers: IResolvers = {
       checkPermission(context, ['INSTRUCTOR'])
       await context.db.mutation.updateTraining({
         where: { id: args.training },
-        data: { published: args.status }
+        data: { published: args.status },
       })
       return 'Success!'
-    }
-  }
+    },
+  },
 }

+ 22 - 32
backend/src/user/resolvers.ts

@@ -18,7 +18,7 @@ export const resolvers: IResolvers = {
       checkPermission(context)
       return context.db.query.user(
         {
-          where: { id: context.req.userId }
+          where: { id: context.req.userId },
         },
         info
       )
@@ -28,7 +28,7 @@ export const resolvers: IResolvers = {
     users: async (parent, args, context, info) => {
       checkPermission(context, 'ADMIN')
       return context.db.query.users(null, info)
-    }
+    },
   },
 
   Mutation: {
@@ -41,15 +41,15 @@ export const resolvers: IResolvers = {
           data: {
             ...args,
             email: lowercaseEmail,
-            password: encryptedPassword
-          }
+            password: encryptedPassword,
+          },
         },
         info
       )
       const token = jwt.sign({ userId: user.id }, process.env.APP_SECRET || '')
       ctx.res.cookie('token', token, {
         httpOnly: true,
-        maxAge: 24 * 60 * 60 * 1000
+        maxAge: 24 * 60 * 60 * 1000,
       })
       return user
     },
@@ -64,7 +64,7 @@ export const resolvers: IResolvers = {
         token,
         {
           httpOnly: true,
-          maxAge: 7 * 24 * 3600 * 1000
+          maxAge: 7 * 24 * 3600 * 1000,
         },
         info
       )
@@ -82,7 +82,7 @@ export const resolvers: IResolvers = {
       const resetTokenExpiry = Date.now() + 3600000 // 1 hour from now
       await context.db.mutation.updateUser({
         where: { email },
-        data: { resetToken, resetTokenExpiry }
+        data: { resetToken, resetTokenExpiry },
       })
       /* await transport.sendMail({
         from: 'wes@wesbos.com',
@@ -99,8 +99,8 @@ export const resolvers: IResolvers = {
       const [user] = await context.db.query.users({
         where: {
           resetToken: token,
-          resetTokenExpiry_gte: Date.now() - 3600000
-        }
+          resetTokenExpiry_gte: Date.now() - 3600000,
+        },
       })
       if (!user) {
         throw Error('Token invalid or expired.')
@@ -111,16 +111,13 @@ export const resolvers: IResolvers = {
         data: {
           password: encryptedPassword,
           resetToken: null,
-          resetTokenExpiry: null
-        }
+          resetTokenExpiry: null,
+        },
       })
-      const cookieToken = jwt.sign(
-        { userId: updatedUser.id },
-        process.env.APP_SECRET || ''
-      )
+      const cookieToken = jwt.sign({ userId: updatedUser.id }, process.env.APP_SECRET || '')
       context.res.cookie('token', cookieToken, {
         httpOnly: true,
-        maxAge: 1000 * 60 * 60 * 24 * 365
+        maxAge: 1000 * 60 * 60 * 24 * 365,
       })
       return updatedUser
     },
@@ -135,8 +132,8 @@ export const resolvers: IResolvers = {
           data: {
             ...args,
             email: lowercaseEmail,
-            password: encryptedPassword
-          }
+            password: encryptedPassword,
+          },
         },
         info
       )
@@ -149,9 +146,9 @@ export const resolvers: IResolvers = {
       return context.db.mutation.updateUser(
         {
           data: {
-            ...updatedData
+            ...updatedData,
           },
-          where: { email }
+          where: { email },
         },
         info
       )
@@ -159,8 +156,8 @@ export const resolvers: IResolvers = {
     deleteUser: (parent, { email }, context, info) => {
       checkPermission(context, 'ADMIN')
       return context.db.mutation.deleteUser({ where: { email } })
-    }
-  }
+    },
+  },
 }
 
 /**
@@ -172,21 +169,14 @@ export function checkPermission(context: any, permission?: string | string[]) {
   if (!context.req.userId) throw new Error('Login required.')
   if (typeof permission === 'string') {
     if (!context.req.user.permissions.includes(permission))
-      throw Error(
-        `No permission. This operation requires ${permission} privilege.`
-      )
+      throw Error(`No permission. This operation requires ${permission} privilege.`)
   } else if (Array.isArray(permission)) {
     if (
       !context.req.user.permissions.reduce(
-        (state: boolean, userPermission: string) =>
-          state || permission.includes(userPermission),
+        (state: boolean, userPermission: string) => state || permission.includes(userPermission),
         false
       )
     )
-      throw Error(
-        `No permission. This operation requires ${permission.join(
-          ' or '
-        )} privilege.`
-      )
+      throw Error(`No permission. This operation requires ${permission.join(' or ')} privilege.`)
   }
 }

+ 15 - 4
frontend/.vscode/settings.json

@@ -1,5 +1,16 @@
 {
-    "prettier.jsxSingleQuote": true,
-    "prettier.semi": false,
-    "prettier.singleQuote": true
-}
+  "prettier.jsxSingleQuote": true,
+  "prettier.semi": false,
+  "prettier.singleQuote": true,
+  "editor.tabSize": 2,
+  "prettier.printWidth": 100,
+  "workbench.colorTheme": "Cobalt2",
+  "workbench.colorCustomizations": {
+    "[Cobalt2]": {
+      "titleBar.activeForeground": "#e0e0e0",
+      "titleBar.activeBackground": "#001486",
+      "titleBar.inactiveBackground": "#e0e0e0",
+      "titleBar.inactiveForeground": "#000b4b"
+    }
+  }
+}

+ 9 - 31
frontend/pages/_app.tsx

@@ -1,39 +1,17 @@
-import App from 'next/app'
+import { AppProps } from 'next/app'
 import { ApolloProvider } from '@apollo/client'
 
-import Page from '../src/app/components/Page'
 import client from '../src/lib/apollo'
-import { StoreProvider } from '../src/lib/store'
 import { UserProvider } from '../src/user/hooks'
-import { useCurrentUserQuery } from '../src/gql'
 
-class MyApp extends App {
-  static async getInitialProps({ Component, ctx }: any) {
-    let pageProps: any = {}
-
-    if (Component.getInitialProps) {
-      pageProps = await Component.getInitialProps(ctx)
-    }
-
-    // Add the query object to the pageProps
-    // https://github.com/wesbos/Advanced-React/blob/master/finished-application/frontend/pages/_app.js
-    pageProps.query = ctx.query
-    return { pageProps }
-  }
-
-  render() {
-    const { Component, pageProps } = this.props
-
-    return (
-      <ApolloProvider client={client}>
-        <UserProvider>
-          <Page>
-            <Component {...pageProps} />
-          </Page>
-        </UserProvider>
-      </ApolloProvider>
-    )
-  }
+const MyApp = ({ Component, pageProps }: AppProps) => {
+  return (
+    <ApolloProvider client={client}>
+      <UserProvider>
+        <Component {...pageProps} />
+      </UserProvider>
+    </ApolloProvider>
+  )
 }
 
 export default MyApp

+ 35 - 6
frontend/pages/admin/index.tsx

@@ -1,11 +1,40 @@
-import Link from 'next/link'
+import { FunctionComponent } from 'react'
+import { AdminPage } from '../../src/app'
+import TileContainer from '../../src/app/components/TileContainer'
+import Tile from '../../src/app/components/Tile'
+import {
+  faCube,
+  faCubes,
+  faDumbbell,
+  faRunning,
+  faWalking,
+} from '@fortawesome/free-solid-svg-icons'
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
 
-const AdminPage = () => {
+const AdminOverview: FunctionComponent = () => {
   return (
-    <Link href='training'>
-      <a>Training</a>
-    </Link>
+    <AdminPage>
+      <TileContainer>
+        <Tile>
+          <h2>
+            Trainings
+            <span className='icon'>
+              <FontAwesomeIcon icon={faRunning} height='1em' />
+            </span>
+          </h2>
+          <p>
+            <span className='count'>4</span> Trainings
+          </p>
+          <p>
+            <span className='count'>10</span> Blocks
+          </p>
+          <p>
+            <span className='count'>6</span> Exercises
+          </p>
+        </Tile>
+      </TileContainer>
+    </AdminPage>
   )
 }
 
-export default AdminPage
+export default AdminOverview

+ 113 - 37
frontend/pages/index.tsx

@@ -1,49 +1,125 @@
-import Link from 'next/link'
-
 //import initialData from '../initial-data'
 import { usePublishedTrainingsQuery } from '../src/gql'
-import { Training } from '../src/training'
+import { Training, TrainingArchive } from '../src/training'
+import { Page } from '../src/app'
+import { useState, useEffect } from 'react'
+import { TTraining } from '../src/training/types'
+import TrainingProgram from '../src/training/components/TrainingProgram'
+import TrainingMeta from '../src/training/components/TrainingMeta'
+import theme from '../src/styles/theme'
+import { formatTime, calculateDuration } from '../src/training/utils'
 
 const Home = () => {
   const { data, error, loading } = usePublishedTrainingsQuery()
+  const [training, setTraining] = useState<undefined | TTraining>()
+
+  useEffect(() => {
+    if (data?.publishedTrainings && data.publishedTrainings.length > 0) {
+      setTraining(data.publishedTrainings[0])
+    }
+  }, [data])
 
   return (
-    <>
-      <section>
-        <h1>Stay in Shape with u-fit</h1>
-        <p>u-fit is a high intensity interval training offered by u-blox.</p>
-        <aside>
-          <div className='info'>
-            <span className='caption'>When</span>
-            <span className='data'>Tuesdays, 11:45-12:30</span>
-          </div>
-          <div className='info'>
-            <span className='caption'>Equipment</span>
-            <span className='data'>Towel, water, optional: yoga mat</span>
-          </div>
-        </aside>
-      </section>
+    <Page>
+      <section className='next-training'>
+        {training ? (
+          <>
+            <h1 className='training-title'>{training.title}</h1>
+            <TrainingMeta training={training} />
+            <TrainingProgram training={training} />
+          </>
+        ) : null}
 
-      <section id='nextTraining'>
-        {loading && <p>Loading trainings...</p>}
-        {error && <p>Error loading trainings: {error.message}</p>}
-        {data?.publishedTrainings && data.publishedTrainings.length > 0 ? (
-          <Training training={data.publishedTrainings[0]} />
-        ) : (
-          <p>No trainings found.</p>
-        )}
-      </section>
+        <TrainingArchive />
+
+        <style jsx>
+          {`
+            .next-training {
+              padding: 1em 0.3em;
+            }
+            .training-title {
+              grid-area: title;
+              margin: 0.3em 0;
+              padding: 0;
+              font-size: 3.4rem;
+              font-weight: 300;
+              text-transform: uppercase;
+              color: ${theme.colors.blue};
+              padding: 0;
+              margin: 0.3em 0;
+              position: relative;
+            }
+            .training-title::before {
+              content: 'your next training:';
+              text-transform: none;
+              font-weight: 900;
+              font-size: 1.1rem;
+              display: block;
+              position: absolute;
+              top: -1rem;
+              left: 01rem;
+              color: ${theme.colors.darkerblue};
+            }
 
-      <style jsx>
-        {`
-          .info .caption {
-            display: inline-block;
-            font-weight: 900;
-            min-width: 6rem;
-          }
-        `}
-      </style>
-    </>
+            :global(.training-program) {
+              grid-area: program;
+            }
+            :global(.training-archive) {
+              grid-area: archive;
+            }
+            :global(.training-meta) {
+              grid-area: info;
+            }
+            @media (min-width: 600px) {
+              .next-training {
+                display: grid;
+                grid-template-columns: 1fr auto;
+                grid-template-areas:
+                  'title    info'
+                  'program  program'
+                  'archive  archive';
+                column-gap: 1em;
+                padding: 2em 3em;
+              }
+            }
+            @media (min-width: 1024px) {
+              .next-training {
+                display: grid;
+                grid-template-columns: 2fr 1fr;
+                grid-template-rows: auto 1fr auto;
+                grid-template-areas:
+                  'title    program'
+                  'info     program'
+                  'archive  program';
+                column-gap: 2em;
+                padding: 2em 3em;
+              }
+              .training-title {
+                font-size: 8rem;
+                margin: 0;
+              }
+              .training-title::before {
+                font-size: 1.4rem;
+                display: block;
+                position: absolute;
+                top: -0.4rem;
+                left: 2.4rem;
+                color: ${theme.colors.darkerblue};
+              }
+              :global(.training-meta) {
+                grid-area: info;
+                height: 350px;
+                background-image: url('/media/man_working_out.jpg');
+                background-size: auto 100%;
+                background-repeat: no-repeat;
+                background-position: right center;
+                clip-path: polygon(0% 0%, 95% 0%, 100% 50%, 95% 100%, 0% 100%, 0% 0%);
+              }
+            }
+          `}
+        </style>
+      </section>
+    </Page>
   )
 }
 

+ 9 - 4
frontend/pages/timer/[id].tsx

@@ -2,18 +2,23 @@
 import { Timer } from '../../src/timer'
 import { useRouter } from 'next/router'
 import { useTrainingQuery } from '../../src/gql'
+import { Page } from '../../src/app'
 
 const TimerPage = () => {
   const router = useRouter()
   const { id } = router.query
 
   const { data, error, loading } = useTrainingQuery({
-    variables: { id: typeof id === 'string' ? id : id[0] }
+    variables: { id: typeof id === 'string' ? id : id[0] },
   })
 
-  if (loading) return <p>Loading data...</p>
-  if (error) return <p>Error loading data.</p>
-  if (data?.training) return <Timer training={data.training} />
+  return (
+    <Page>
+      {loading && <p>Loading data...</p>}
+      {error && <p>Error loading training.</p>}
+      {data?.training && <Timer training={data.training} />}
+    </Page>
+  )
 }
 
 export default TimerPage

+ 0 - 37
frontend/pages/user.tsx

@@ -1,37 +0,0 @@
-import { withRouter } from 'next/router'
-
-import { useCurrentUserQuery } from '../src/gql'
-
-import {
-  SignupForm,
-  LoginForm,
-  LogoutButton,
-  RequestPassword,
-  ResetPassword,
-  UserDetails,
-  DeleteUserButton
-} from '../src/user'
-
-const UserPage = () => {
-  const { data, loading, error } = useCurrentUserQuery()
-  console.log('UserPage', data, loading, error && error.message)
-  //const user = data && data.me
-
-  if (loading) return <p>Loading user data...</p>
-  if (error) return <p>Error loading user data.</p>
-
-  return (
-    <>
-      {/*loading && <p>'Loading'</p>}
-      {user ? <LogoutButton /> : <LoginForm />}
-      <SignupForm />
-      <RequestPassword />
-      <ResetPassword />
-      {user && <UserDetails user={user} />}
-  {user && <DeleteUserButton user={user} />*/}
-      <p>nothing here.</p>
-    </>
-  )
-}
-
-export default withRouter(UserPage)

+ 14 - 0
frontend/src/app/components/AdminPage.tsx

@@ -0,0 +1,14 @@
+import { FunctionComponent } from 'react'
+import AdminSideBar from './AdminSideBar'
+
+const AdminPage: FunctionComponent = ({ children }) => {
+  return (
+    <section>
+      <AdminSideBar />
+
+      <article>{children}</article>
+    </section>
+  )
+}
+
+export default AdminPage

+ 22 - 0
frontend/src/app/components/AdminSideBar.tsx

@@ -0,0 +1,22 @@
+import Link from 'next/link'
+
+const AdminSideBar = () => {
+  return (
+    <aside className='admin-sidebar'>
+      <ul className='admin-menu'>
+        <li className='admin-item'>
+          <Link href='training'>
+            <a>Trainings</a>
+          </Link>
+        </li>
+        <li className='admin-item'>
+          <Link href='user'>
+            <a>Users</a>
+          </Link>
+        </li>
+      </ul>
+    </aside>
+  )
+}
+
+export default AdminSideBar

+ 4 - 5
frontend/src/app/components/Page.tsx

@@ -2,19 +2,18 @@ import Header from './Header'
 import Meta from './Meta'
 import Footer from './Footer'
 import GlobalStyle from '../../styles/global'
-import { useContext } from 'react'
+import { useContext, FunctionComponent } from 'react'
 import { UserContext } from '../../user/hooks'
-import { LoginForm } from '../../user'
+import { LoginPage } from '../../user'
 
-const Page = (props: any) => {
+const Page: FunctionComponent = ({ children }) => {
   const user = useContext(UserContext)
-  console.log(user)
   return (
     <>
       <Meta />
 
       <Header />
-      <main>{user.user ? props.children : <LoginForm />}</main>
+      <main>{user.user?.data?.currentUser ? children : <LoginPage />}</main>
       <Footer />
 
       <style jsx global>

+ 40 - 0
frontend/src/app/components/Tile.tsx

@@ -0,0 +1,40 @@
+import { FunctionComponent } from 'react'
+
+const Tile: FunctionComponent = ({ children }) => {
+  return (
+    <div className='tile'>
+      {children}
+      <style jsx>{`
+        .tile {
+          position: relative;
+          background-color: white;
+          box-shadow: 0px 0px 8px 4px #0001;
+          padding: 1em;
+          min-width: 100%;
+        }
+
+        :global(.tile p),
+        :global(.tile h2) {
+          padding: 0;
+          margin: 0 0 0.7em;
+        }
+
+        :global(.tile h2 .icon) {
+          position: absolute;
+          right: 1em;
+        }
+
+        :global(.tile .count) {
+          font-size: 120%;
+          font-weight: 900;
+        }
+
+        :global(.tile img) {
+          max-width: 100%;
+        }
+      `}</style>
+    </div>
+  )
+}
+
+export default Tile

+ 30 - 0
frontend/src/app/components/TileContainer.tsx

@@ -0,0 +1,30 @@
+import { FunctionComponent } from 'react'
+
+const TileContainer: FunctionComponent = ({ children }) => {
+  return (
+    <div className='tile-container'>
+      {children}
+      <style jsx>{`
+        .tile-container {
+          display: grid;
+          grid-template-columns: 1fr;
+        }
+
+        @media (min-width: 768px) {
+          .tile-container {
+            grid-template-columns: 1fr 1fr;
+            grid-gap: 2em;
+          }
+        }
+
+        @media (min-width: 1024px) {
+          .tile-container {
+            grid-template-columns: 1fr 1fr 1fr;
+          }
+        }
+      `}</style>
+    </div>
+  )
+}
+
+export default TileContainer

+ 8 - 0
frontend/src/app/index.tsx

@@ -0,0 +1,8 @@
+import AdminPage from './components/AdminPage'
+import Page from './components/Page'
+import Header from './components/Header'
+import Footer from './components/Footer'
+import Meta from './components/Meta'
+import Nav from './components/Nav'
+
+export { Page, AdminPage, Header, Footer, Meta, Nav }

+ 5 - 15
frontend/src/form/components/TextInput.tsx

@@ -3,28 +3,17 @@ import { DetailedHTMLProps, InputHTMLAttributes, ChangeEvent } from 'react'
 type ITextInput = {
   onChange: GenericEventHandler
   label?: string
-} & Omit<
-  DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>,
-  'onChange'
->
+} & Omit<DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>, 'onChange'>
 
-const TextInput = ({
-  name,
-  label,
-  id,
-  type,
-  onChange,
-  value,
-  ...props
-}: ITextInput) => {
+const TextInput = ({ name, label, id, type, onChange, value, ...props }: ITextInput) => {
   function handleChange(event: ChangeEvent<HTMLInputElement>) {
     if (event.target.type === 'number') {
       const newValue = {
         target: {
           type: 'custom',
           value: parseInt(event.target.value) ?? undefined,
-          name: event.target.name
-        }
+          name: event.target.name,
+        },
       }
       onChange(newValue)
     } else return onChange(event)
@@ -39,6 +28,7 @@ const TextInput = ({
         name={name}
         value={value ?? ''}
         type={type}
+        placeholder={label}
         onChange={handleChange}
         {...props}
       />

+ 63 - 4
frontend/src/gql/index.tsx

@@ -15,6 +15,10 @@ export type Scalars = {
   Upload: any,
 };
 
+export type AggregateTraining = {
+  count: Scalars['Int'],
+};
+
 export type Block = Node & {
   id: Scalars['ID'],
   title: Scalars['String'],
@@ -1634,6 +1638,18 @@ export type Node = {
   id: Scalars['ID'],
 };
 
+/** Information about pagination in a connection. */
+export type PageInfo = {
+  /** When paginating forwards, are there more items? */
+  hasNextPage: Scalars['Boolean'],
+  /** When paginating backwards, are there more items? */
+  hasPreviousPage: Scalars['Boolean'],
+  /** When paginating backwards, the cursor to continue. */
+  startCursor?: Maybe<Scalars['String']>,
+  /** When paginating forwards, the cursor to continue. */
+  endCursor?: Maybe<Scalars['String']>,
+};
+
 export enum Permission {
   Admin = 'ADMIN',
   Instructor = 'INSTRUCTOR'
@@ -1648,6 +1664,7 @@ export type Query = {
   training?: Maybe<Training>,
   trainings: Array<Training>,
   publishedTrainings: Array<Training>,
+  trainingsCount: TrainingConnection,
   trainingType?: Maybe<TrainingType>,
   trainingTypes: Array<TrainingType>,
   block?: Maybe<Block>,
@@ -1696,6 +1713,11 @@ export type QueryTrainingsArgs = {
 };
 
 
+export type QueryTrainingsCountArgs = {
+  where?: Maybe<TrainingWhereInput>
+};
+
+
 export type QueryTrainingTypeArgs = {
   where: TrainingTypeWhereUniqueInput
 };
@@ -2450,6 +2472,15 @@ export type TrainingBlocksArgs = {
   last?: Maybe<Scalars['Int']>
 };
 
+/** A connection to a list of items. */
+export type TrainingConnection = {
+  /** Information to aid in pagination. */
+  pageInfo: PageInfo,
+  /** A list of edges. */
+  edges: Array<Maybe<TrainingEdge>>,
+  aggregate: AggregateTraining,
+};
+
 export type TrainingCreateOneWithoutBlocksInput = {
   create?: Maybe<TrainingCreateWithoutBlocksInput>,
   connect?: Maybe<TrainingWhereUniqueInput>,
@@ -2467,6 +2498,14 @@ export type TrainingCreateWithoutBlocksInput = {
   ratings?: Maybe<RatingCreateManyInput>,
 };
 
+/** An edge in a connection. */
+export type TrainingEdge = {
+  /** The item at the end of the edge. */
+  node: Training,
+  /** A cursor for use in pagination. */
+  cursor: Scalars['String'],
+};
+
 export enum TrainingOrderByInput {
   IdAsc = 'id_ASC',
   IdDesc = 'id_DESC',
@@ -3442,10 +3481,18 @@ export type PublishedTrainingsQueryVariables = {};
 
 export type PublishedTrainingsQuery = { publishedTrainings: Array<DisplayTrainingFragment> };
 
-export type TrainingsQueryVariables = {};
+export type TrainingsQueryVariables = {
+  where?: Maybe<TrainingWhereInput>,
+  orderBy?: Maybe<TrainingOrderByInput>,
+  skip?: Maybe<Scalars['Int']>,
+  after?: Maybe<Scalars['String']>,
+  before?: Maybe<Scalars['String']>,
+  first?: Maybe<Scalars['Int']>,
+  last?: Maybe<Scalars['Int']>
+};
 
 
-export type TrainingsQuery = { trainings: Array<(
+export type TrainingsQuery = { count: { aggregate: Pick<AggregateTraining, 'count'> }, trainings: Array<(
     Pick<Training, 'id' | 'title' | 'trainingDate' | 'location' | 'attendance' | 'published'>
     & { type: Pick<TrainingType, 'id' | 'name' | 'description'>, blocks: Maybe<Array<(
       { block: (
@@ -3848,8 +3895,13 @@ export type PublishedTrainingsQueryHookResult = ReturnType<typeof usePublishedTr
 export type PublishedTrainingsLazyQueryHookResult = ReturnType<typeof usePublishedTrainingsLazyQuery>;
 export type PublishedTrainingsQueryResult = ApolloReactCommon.QueryResult<PublishedTrainingsQuery, PublishedTrainingsQueryVariables>;
 export const TrainingsDocument = gql`
-    query trainings {
-  trainings {
+    query trainings($where: TrainingWhereInput, $orderBy: TrainingOrderByInput, $skip: Int, $after: String, $before: String, $first: Int, $last: Int) {
+  count: trainingsCount(where: $where) {
+    aggregate {
+      count
+    }
+  }
+  trainings(where: $where, orderBy: $orderBy, skip: $skip, after: $after, before: $before, first: $first, last: $last) {
     id
     title
     type {
@@ -3900,6 +3952,13 @@ ${BlockWithoutBlocksFragmentDoc}`;
  * @example
  * const { data, loading, error } = useTrainingsQuery({
  *   variables: {
+ *      where: // value for 'where'
+ *      orderBy: // value for 'orderBy'
+ *      skip: // value for 'skip'
+ *      after: // value for 'after'
+ *      before: // value for 'before'
+ *      first: // value for 'first'
+ *      last: // value for 'last'
  *   },
  * });
  */

+ 31 - 3
frontend/src/styles/global.ts

@@ -29,7 +29,7 @@ const GlobalStyle = css.global`
       'header'
       'main'
       'footer';
-    grid-template-rows: auto auto 1fr minmax(140px, auto);
+    grid-template-rows: auto 1fr minmax(140px, auto);
 
     min-height: 100vh;
 
@@ -54,8 +54,6 @@ const GlobalStyle = css.global`
   }
   main {
     grid-area: main;
-    max-width: ${theme.maxWidth};
-    margin: 0 auto;
   }
   footer {
     grid-area: footer;
@@ -76,6 +74,36 @@ const GlobalStyle = css.global`
   pre {
     font-family: 'Roboto Mono', monospace;
   }
+
+  form * {
+    font-size: 120%;
+    padding: 0.3em 0.8em;
+    margin: 0.8em auto;
+  }
+
+  form label {
+    display: none;
+  }
+  form input {
+    display: block;
+    width: 100%;
+    border: 2px solid ${theme.colors.darkerblue}00;
+    color: ${theme.colors.darkerblue};
+    background-color: ${theme.colors.darkerblue}27;
+    transition: all 250ms ease-in-out;
+  }
+  form input:focus {
+    color: ${theme.colors.blue};
+    background-color: ${theme.colors.blue}15;
+    border-bottom: 2px solid ${theme.colors.blue}44;
+  }
+
+  form button {
+    display: block;
+    border: none;
+    background-color: ${theme.colors.darkerblue};
+    color: ${theme.colors.offWhite};
+  }
 `
 
 export default GlobalStyle

+ 12 - 23
frontend/src/timer/utils.ts

@@ -1,6 +1,6 @@
 import { calculateDuration } from '../training/utils'
 import { IExerciseItem } from './types'
-import { TBlock, TBlockInstance } from '../training/types'
+import { TBlockInstance } from '../training/types'
 
 /**
  * Find the right exercise given a certain time.
@@ -8,21 +8,17 @@ import { TBlock, TBlockInstance } from '../training/types'
  * @param time
  */
 export function getPosition(exerciseList: IExerciseItem[], time: number) {
-  const index = exerciseList.findIndex(
-    exercise => time < exercise.offset + exercise.duration
-  )
+  const index = exerciseList.findIndex((exercise) => time < exercise.offset + exercise.duration)
 
   const previousExercise = index >= 1 ? exerciseList[index - 1] : null
   const currentExercise = index >= 0 ? exerciseList[index] : null
   const nextExercise =
-    index >= 0 && index < exerciseList.length - 1
-      ? exerciseList[index + 1]
-      : null
+    index >= 0 && index < exerciseList.length - 1 ? exerciseList[index + 1] : null
   const values = {
     currentExercise,
     nextExercise,
     previousExercise,
-    exerciseTime: 0
+    exerciseTime: 0,
   }
   if (currentExercise !== null) {
     values.exerciseTime = Math.max(time - currentExercise.offset, 0)
@@ -43,16 +39,12 @@ export function getExerciseList(
   if (!blockInstances?.length) return []
   let offset = initialOffset
   return blockInstances
-    .map(blockInstance => {
+    .map((blockInstance) => {
       const { block, rounds = 1 } = blockInstance
       if (block.blocks?.length) {
         const blockArray = []
         for (let i = 0; i < (rounds ?? 1); i++) {
-          const subBlocks = getExerciseList(
-            block.blocks,
-            offset,
-            toplevelBlock || block.title
-          )
+          const subBlocks = getExerciseList(block.blocks, offset, toplevelBlock || block.title)
           const lastItem = subBlocks[subBlocks.length - 1]
           if (lastItem) {
             offset = lastItem.offset + lastItem.duration
@@ -66,7 +58,7 @@ export function getExerciseList(
                 exercise: 'Rest',
                 toplevelBlock: toplevelBlock || block.title,
                 duration: block.rest,
-                offset
+                offset,
               })
               offset += block.rest
             }
@@ -79,10 +71,10 @@ export function getExerciseList(
         const blockArray: IExerciseItem[] = []
         const newItem = {
           exercise: block.exercises
-            .map(exerciseInstance => {
+            .map((exerciseInstance) => {
               const {
                 exercise: { name },
-                repetitions = 1
+                repetitions = 1,
               } = exerciseInstance
               return repetitions > 1 ? `${repetitions}x ${name}` : name
             })
@@ -91,7 +83,7 @@ export function getExerciseList(
           videos: block.videos,
           description: block.description,
           toplevelBlock: toplevelBlock || block.title,
-          offset
+          offset,
         }
         for (let i = 0; i < (rounds ?? 1); i++) {
           blockArray.push({ ...newItem, offset })
@@ -105,10 +97,7 @@ export function getExerciseList(
 }
 
 export function getTrainingTime(exercises: IExerciseItem[]) {
-  return exercises.reduce(
-    (accumulator, exercise) => accumulator + exercise.duration,
-    0
-  )
+  return exercises.reduce((accumulator, exercise) => accumulator + exercise.duration, 0)
 }
 
 export function polarToCartesian(
@@ -121,7 +110,7 @@ export function polarToCartesian(
 
   return {
     x: centerX + radius * Math.cos(angleInRadians),
-    y: centerY + radius * Math.sin(angleInRadians)
+    y: centerY + radius * Math.sin(angleInRadians),
   }
 }
 

+ 5 - 5
frontend/src/training/components/ExerciseComposition.tsx

@@ -16,14 +16,14 @@ const ExerciseComposition = ({ exercises, duration }: IExerciseComposition) => {
 
       <style jsx>
         {`
+          }
           .exercise-composition {
-            display: grid;
-            grid-template-columns: 5fr 1fr;
+            display: flex;
+            justify-content: space-between;
+            align-items: end;
           }
           .exercise-composition .exercise-time {
-            text-align: right;
-          }
-          .exercise-time {
+            right: 0;
             color: gray;
             font-size: 80%;
           }

+ 6 - 29
frontend/src/training/components/Training.tsx

@@ -5,32 +5,19 @@ import Link from 'next/link'
 import { TTraining } from '../types'
 import TrainingMeta from './TrainingMeta'
 import { formatTime, calculateDuration } from '../utils'
+import TrainingProgram from './TrainingProgram'
 
 const Training = ({ training }: { training: TTraining }) => {
   return (
     <article>
-      <h2>{training.title}</h2>
+      <h2>
+        <span className='program-title'>{training.title}</span>{' '}
+        <span className='program-time'>{formatTime(calculateDuration(training.blocks))}</span>
+      </h2>
 
       <TrainingMeta training={training} />
 
-      <section>
-        <h2>
-          <span className='program-title'>{training.title}</span>{' '}
-          <span className='program-time'>
-            {formatTime(calculateDuration(training.blocks))}
-          </span>
-        </h2>
-        <Link href='/timer/[id]' as={`/timer/${training.id}`}>
-          <button type='button'>Start Timer</button>
-        </Link>
-        {training.blocks &&
-          training.blocks
-            .slice()
-            .sort((a, b) => a.order - b.order)
-            .map(block => (
-              <TrainingBlock key={block.id} blockInstance={block} />
-            ))}
-      </section>
+      <TrainingProgram training={training} />
 
       <style jsx>
         {`
@@ -77,16 +64,6 @@ const Training = ({ training }: { training: TTraining }) => {
             padding: 0.5rem;
             cursor: pointer;
           }
-          .program-time {
-            color: gray;
-            font-size: 90%;
-          }
-          .program-time::before {
-            content: '(';
-          }
-          .program-time::after {
-            content: ')';
-          }
         `}
       </style>
     </article>

+ 59 - 0
frontend/src/training/components/TrainingArchive.tsx

@@ -0,0 +1,59 @@
+import { useTrainingsQuery } from '../../gql'
+import { useState } from 'react'
+import TrainingHint from './TrainingHint'
+import theme from '../../styles/theme'
+
+const TrainingArchive = () => {
+  const trainingsPerPage = 1
+  const [state, setState] = useState(0)
+  const { data, error, loading } = useTrainingsQuery({
+    variables: { skip: state * trainingsPerPage, first: trainingsPerPage },
+  })
+  if (loading) return <p>Loading trainings...</p>
+  if (error) return <p>Error loading trainings.</p>
+  if (!data) return null
+  const { trainings, count } = data
+  const pages = []
+
+  for (let index = 0; index < count.aggregate.count / trainingsPerPage; index++) {
+    pages.push(index)
+  }
+
+  return (
+    <section className='training-archive'>
+      <h2>Training Archive</h2>
+      <ol>
+        {trainings.map((training) => (
+          <TrainingHint key={training.id} training={training} />
+        ))}
+      </ol>
+      {pages.map((index) => (
+        <button
+          key={index}
+          onClick={(event) => setState(index)}
+          className={index === state ? 'active' : undefined}
+        >
+          {index + 1}
+        </button>
+      ))}
+
+      <style jsx>{`
+        button {
+          border: none;
+          padding: 0.3em 0.6em;
+          margin: 0 0.3em;
+          background-color: none;
+          color: ${theme.colors.darkblue};
+          border-radius: 6px;
+          cursor: pointer;
+        }
+        button.active {
+          background-color: ${theme.colors.darkblue};
+          color: ${theme.colors.offWhite};
+        }
+      `}</style>
+    </section>
+  )
+}
+
+export default TrainingArchive

+ 3 - 15
frontend/src/training/components/TrainingBlock.tsx

@@ -2,11 +2,7 @@ import ExerciseComposition from './ExerciseComposition'
 import { calculateDuration, formatTime } from '../utils'
 import { TBlockInstance } from '../types'
 
-const TrainingBlock = ({
-  blockInstance
-}: {
-  blockInstance: TBlockInstance
-}) => {
+const TrainingBlock = ({ blockInstance }: { blockInstance: TBlockInstance }) => {
   const duration = calculateDuration(blockInstance)
   const { title, blocks, exercises } = blockInstance.block
   return (
@@ -17,19 +13,11 @@ const TrainingBlock = ({
           <span className='block-time'>{formatTime(duration)}</span>
         </h3>
       )}
-      {blocks &&
-        blocks.map(block => (
-          <TrainingBlock key={block.id} blockInstance={block} />
-        ))}
-      {exercises?.length ? (
-        <ExerciseComposition exercises={exercises} duration={duration} />
-      ) : null}
+      {blocks && blocks.map((block) => <TrainingBlock key={block.id} blockInstance={block} />)}
+      {exercises?.length ? <ExerciseComposition exercises={exercises} duration={duration} /> : null}
 
       <style jsx>
         {`
-          section {
-            display: grid;
-          }
           .block-time {
             color: gray;
             font-size: 90%;

+ 13 - 0
frontend/src/training/components/TrainingHint.tsx

@@ -0,0 +1,13 @@
+import { FunctionComponent } from 'react'
+import { TTraining } from '../types'
+
+const TrainingHint: FunctionComponent<{ training: TTraining }> = ({ training }) => {
+  return (
+    <div>
+      <div>{training.title}</div>
+      <div>{new Date(training.trainingDate).toLocaleString()}</div>
+    </div>
+  )
+}
+
+export default TrainingHint

+ 27 - 31
frontend/src/training/components/TrainingMeta.tsx

@@ -1,5 +1,5 @@
 import { TTraining } from '../types'
-import { calculateRating } from '../utils'
+import { calculateRating, formatTime, calculateDuration } from '../utils'
 import { useContext } from 'react'
 import { UserContext } from '../../user/hooks'
 import { useRegisterMutation, useDeregisterMutation } from '../../gql'
@@ -7,41 +7,45 @@ import { useRegisterMutation, useDeregisterMutation } from '../../gql'
 const TrainingMeta = ({ training }: { training: TTraining }) => {
   const { user } = useContext(UserContext)
   const [register, registerData] = useRegisterMutation({
-    variables: { training: training.id }
+    variables: { training: training.id },
   })
   const [deregister, deregisterData] = useDeregisterMutation({
-    variables: { training: training.id }
+    variables: { training: training.id },
   })
 
   return (
-    <aside>
+    <aside className='training-meta'>
       <div className='info'>
-        <span className='caption'>Type: </span>
+        <span className='caption'>Type</span>
         <span className='data'>{training.type.name}</span>
       </div>
       <div className='info'>
-        <span className='caption'>Date: </span>
-        <span className='data'>
-          {new Date(training.trainingDate).toLocaleString()}
-        </span>
+        <span className='caption'>Date</span>
+        <span className='data'>{new Date(training.trainingDate).toLocaleString()}</span>
+      </div>
+      <div className='info'>
+        <span className='caption'>Duration</span>
+        <span className='data'>{formatTime(calculateDuration(training.blocks))} minutes</span>
       </div>
+
       <div className='info'>
-        <span className='caption'>Location: </span>
+        <span className='caption'>Location</span>
         <span className='data'>{training.location}</span>
       </div>
       <div className='info'>
-        <span className='caption'>Registrations: </span>
-        <span className='data'>{training.registrations?.length ?? 0}</span>
-        {training.registrations &&
-        training.registrations.find(
-          registeredUser =>
-            user?.data?.currentUser &&
-            registeredUser.id === user.data.currentUser.id
-        ) ? (
-          <button onClick={() => deregister()}>Deregister</button>
-        ) : (
-          <button onClick={() => register()}>Register now!</button>
-        )}
+        <span className='caption'>Registrations</span>
+        <span className='data'>
+          {training.registrations?.length ?? 0}
+          {training.registrations &&
+          training.registrations.find(
+            (registeredUser) =>
+              user?.data?.currentUser && registeredUser.id === user.data.currentUser.id
+          ) ? (
+            <button onClick={() => deregister()}>Deregister</button>
+          ) : (
+            <button onClick={() => register()}>Register now!</button>
+          )}
+        </span>
       </div>
       {/*<div className="info">
         <span className="caption">Attendance: </span>
@@ -60,18 +64,10 @@ const TrainingMeta = ({ training }: { training: TTraining }) => {
         </span>
       </div>
           */}
-
       <style jsx>{`
-        aside {
-          grid-area: information;
-          background: rgba(200, 200, 200, 0.8);
-          min-height: 370px;
-          padding: 0.2em 1em;
-          margin: 0;
-        }
         .info .caption {
-          font-weight: 900;
           display: inline-block;
+          font-weight: 900;
           min-width: 7em;
         }
       `}</style>

+ 47 - 0
frontend/src/training/components/TrainingProgram.tsx

@@ -0,0 +1,47 @@
+import { FunctionComponent } from 'react'
+import { TTraining } from '../types'
+import Link from 'next/link'
+import TrainingBlock from './TrainingBlock'
+import theme from '../../styles/theme'
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
+import { faStopwatch20 } from '@fortawesome/free-solid-svg-icons'
+
+const TrainingProgram: FunctionComponent<{ training: TTraining }> = ({ training }) => {
+  return (
+    <section className='training-program'>
+      <Link href='/timer/[id]' as={`/timer/${training.id}`}>
+        <button type='button'>
+          <span>Start Timer</span> <FontAwesomeIcon icon={faStopwatch20} height='1.2em' />
+        </button>
+      </Link>
+      {training.blocks &&
+        training.blocks
+          .slice()
+          .sort((a, b) => a.order - b.order)
+          .map((block) => <TrainingBlock key={block.id} blockInstance={block} />)}
+
+      <style jsx>{`
+        section {
+          max-width: 460px;
+        }
+        button {
+          display: flex;
+          padding: 1em 2em;
+          margin: 0.8em auto;
+          border: none;
+          background-color: ${theme.colors.darkblue};
+          color: ${theme.colors.offWhite};
+          font-size: 140%;
+          text-transform: uppercase;
+          align-items: center;
+          cursor: pointer;
+        }
+        button span {
+          margin-right: 0.5em;
+        }
+      `}</style>
+    </section>
+  )
+}
+
+export default TrainingProgram

+ 0 - 14
frontend/src/training/components/trainingArchive.js

@@ -1,14 +0,0 @@
-import TrainingHint from './trainingHint'
-
-const TrainingArchive = props => (
-  <div>
-    <h2>Training Archive</h2>
-    <ol>
-      {props.trainings.map(training => (
-        <TrainingHint key={training.id} training={training} />
-      ))}
-    </ol>
-  </div>
-)
-
-export default TrainingArchive

+ 0 - 8
frontend/src/training/components/trainingHint.js

@@ -1,8 +0,0 @@
-const TrainingHint = props => (
-  <div>
-    <div>{props.training.date}</div>
-    <div>{props.training.title}</div>
-  </div>
-)
-
-export default TrainingHint

+ 2 - 1
frontend/src/training/index.tsx

@@ -1,3 +1,4 @@
 import Training from './components/Training'
+import TrainingArchive from './components/TrainingArchive'
 
-export { Training }
+export { Training, TrainingArchive }

+ 24 - 6
frontend/src/training/training.graphql

@@ -172,8 +172,29 @@ query publishedTrainings {
   }
 }
 
-query trainings {
-  trainings {
+query trainings(
+  $where: TrainingWhereInput
+  $orderBy: TrainingOrderByInput
+  $skip: Int
+  $after: String
+  $before: String
+  $first: Int
+  $last: Int
+) {
+  count: trainingsCount(where: $where) {
+    aggregate {
+      count
+    }
+  }
+  trainings(
+    where: $where
+    orderBy: $orderBy
+    skip: $skip
+    after: $after
+    before: $before
+    first: $first
+    last: $last
+  ) {
     id
     title
     type {
@@ -266,10 +287,7 @@ mutation createTraining(
   }
 }
 
-mutation updateTraining(
-  $where: TrainingWhereUniqueInput!
-  $data: TrainingUpdateInput!
-) {
+mutation updateTraining($where: TrainingWhereUniqueInput!, $data: TrainingUpdateInput!) {
   updateTraining(where: $where, data: $data) {
     id
   }

+ 50 - 0
frontend/src/user/components/LoginPage.tsx

@@ -0,0 +1,50 @@
+import SignupForm from './SignupForm'
+import { LoginForm } from '..'
+import theme from '../../styles/theme'
+
+const LoginPage = () => {
+  return (
+    <section className='login-page'>
+      <div className='login-tile'>
+        <h2>Join the u-fit Community</h2>
+        <p>
+          u-fit is a short 30-45 minute fitness program. u-blox offers it's employees this training
+          for free.
+        </p>
+        <SignupForm />
+      </div>
+      <div className='login-tile'>
+        <h2>Log in</h2>
+        <p>Already signed up? Welcome back!</p>
+        <LoginForm />
+      </div>
+
+      <style jsx>{`
+        .login-page {
+          display: grid;
+          grid-template-columns: 1fr;
+          align-items: center;
+          background-color: #f7f7f7;
+          border-radius: 8px;
+          margin: 0 auto;
+          padding: 1em;
+          max-width: ${theme.maxWidth};
+          box-shadow: ${theme.bsSmall};
+        }
+
+        @media (min-width: 768px) {
+          .login-page {
+            grid-template-columns: 1fr 1fr;
+            grid-gap: 4em;
+            margin: 2em auto;
+            padding: 2em;
+          }
+          .login-tile {
+          }
+        }
+      `}</style>
+    </section>
+  )
+}
+
+export default LoginPage

+ 60 - 40
frontend/src/user/components/SignupForm.tsx

@@ -1,58 +1,78 @@
-import { useFormHandler, TextInput } from '../../form'
+import { useForm, TextInput } from '../../form'
 import { useUserSignupMutation } from '../../gql'
-import { userValidation } from '../validation'
 
 const initialValues = {
   name: 'Tomi Cvetic',
   email: 'tomislav.cvetic@u-blox.com',
   password: '1234',
-  passwordAgain: '1234'
+  passwordAgain: '1234',
 }
 
 const SignupForm = () => {
-
-  const { inputProps, values } = useFormHandler(initialValues)
+  const { values, onChange } = useForm(initialValues)
   const [userSignup, { loading, error }] = useUserSignupMutation()
 
   return (
-    <form onSubmit={async (event: React.SyntheticEvent) => {
-      event.preventDefault()
-      try {
-        const data = await userSignup({ variables: values })
-      } catch (error) {
-        console.log(error)
-      }
-    }}>
-      <TextInput label='Name' {...inputProps('name')} />
-      <TextInput label='Email' {...inputProps('email')} />
-      <TextInput label='Password' {...inputProps('password')} />
-      <TextInput label='Repeat password' {...inputProps('passwordAgain')} />
-      <button type='reset' disabled={loading}>Reset</button>
-      <button type='submit' disabled={loading}>Sign Up!</button>
+    <form
+      onSubmit={async (event: React.SyntheticEvent) => {
+        event.preventDefault()
+        try {
+          const data = await userSignup({ variables: values })
+        } catch (error) {
+          console.log(error)
+        }
+      }}
+    >
+      <TextInput label='Name' name='name' value={values.name} onChange={onChange} />
+      <TextInput
+        label='E-Mail'
+        name='email'
+        type='email'
+        value={values.email}
+        onChange={onChange}
+      />
+      <TextInput
+        label='Password'
+        name='password'
+        type='password'
+        value={values.password}
+        onChange={onChange}
+      />
+      <TextInput
+        label='Retype password'
+        name='passwordAgain'
+        type='password'
+        value={values.passwordAgain}
+        onChange={onChange}
+      />
+      <button type='submit' disabled={loading}>
+        Sign Up!
+      </button>
 
       <style jsx>
         {`
-            select, input {
-              color: rgba(0,0,127,1);
-            }
-
-            .error {
-              font-size: 12px;
-              color: rgba(127,0,0,1);
-              width: 400px;
-              margin-top: 0.25rem;
-            }
-
-            .error:before {
-              content: "❌ ";
-              font-size: 10px;
-            }
-
-            label {
-              color: red;
-              margin-top: 1rem;
-            }
-          `}
+          select,
+          input {
+            color: rgba(0, 0, 127, 1);
+          }
+
+          .error {
+            font-size: 12px;
+            color: rgba(127, 0, 0, 1);
+            width: 400px;
+            margin-top: 0.25rem;
+          }
+
+          .error::before {
+            content: '❌ ';
+            font-size: 10px;
+          }
+
+          label {
+            color: red;
+            margin-top: 1rem;
+          }
+        `}
       </style>
     </form>
   )

+ 5 - 8
frontend/src/user/hooks.tsx

@@ -3,7 +3,8 @@ import {
   useCurrentUserQuery,
   useUserLogoutMutation,
   useUserLoginMutation,
-  CurrentUserDocument
+  CurrentUserDocument,
+  TrainingsDocument,
 } from '../gql'
 
 interface IUserContext {
@@ -17,14 +18,10 @@ export const UserContext = createContext<IUserContext>({})
 export const UserProvider: FunctionComponent = ({ children }) => {
   const user = useCurrentUserQuery({ fetchPolicy: 'network-only' })
   const logout = useUserLogoutMutation({
-    refetchQueries: [{ query: CurrentUserDocument }]
+    refetchQueries: [{ query: CurrentUserDocument }, { query: TrainingsDocument }],
   })
   const login = useUserLoginMutation({
-    refetchQueries: [{ query: CurrentUserDocument }]
+    refetchQueries: [{ query: CurrentUserDocument }, { query: TrainingsDocument }],
   })
-  return (
-    <UserContext.Provider value={{ user, login, logout }}>
-      {children}
-    </UserContext.Provider>
-  )
+  return <UserContext.Provider value={{ user, login, logout }}>{children}</UserContext.Provider>
 }

+ 3 - 1
frontend/src/user/index.ts

@@ -6,6 +6,7 @@ import LogoutButton from './components/LogoutButton'
 //import UserAdmin from './components/UserAdmin'
 //import UserDetails from './components/UserDetails'
 import DeleteUserButton from './components/DeleteUserButton'
+import LoginPage from './components/LoginPage'
 
 export {
   LoginForm,
@@ -15,5 +16,6 @@ export {
   //SignupForm,
   //UserAdmin,
   //UserDetails,
-  DeleteUserButton
+  DeleteUserButton,
+  LoginPage,
 }