Jelajahi Sumber

refactored and added a timer with countdown.

Tomi Cvetic 4 tahun lalu
induk
melakukan
86ca72bcdb
46 mengubah file dengan 2056 tambahan dan 617 penghapusan
  1. 3 0
      .gitignore
  2. 18 18
      backend/index.js
  3. 0 12
      backend/src/user/authenticate.js
  4. 0 7
      backend/src/user/index.js
  5. 0 154
      backend/src/user/resolvers.js
  6. 1 1
      docker-compose.yml
  7. 0 127
      frontend/components/training/training.js
  8. 0 63
      frontend/components/training/trainingBlock.js
  9. 0 128
      frontend/initial-data.js
  10. 640 0
      frontend/initial-data.ts
  11. 197 0
      frontend/package-lock.json
  12. 4 0
      frontend/package.json
  13. 17 32
      frontend/pages/index.js
  14. 0 5
      frontend/pages/signup.js
  15. 8 0
      frontend/pages/timer.tsx
  16. 6 6
      frontend/pages/training.js
  17. 14 13
      frontend/pages/user.tsx
  18. TEMPAT SAMPAH
      frontend/public/media/Pexels Videos 2786540.mp4
  19. 1 0
      frontend/public/video.css
  20. 9 8
      frontend/src/app/components/Meta.tsx
  21. 8 9
      frontend/src/app/components/Page.tsx
  22. 128 0
      frontend/src/gql/index.tsx
  23. 92 0
      frontend/src/timer/__tests__/utils.test.ts
  24. 16 0
      frontend/src/timer/components/AudioPlayer.tsx
  25. 111 0
      frontend/src/timer/components/Countdown.tsx
  26. 42 0
      frontend/src/timer/components/Indicator.tsx
  27. 196 0
      frontend/src/timer/components/Timer.tsx
  28. 30 0
      frontend/src/timer/components/VideoPlayer.tsx
  29. 3 0
      frontend/src/timer/index.ts
  30. 5 0
      frontend/src/timer/types.ts
  31. 116 0
      frontend/src/timer/utils.ts
  32. 32 0
      frontend/src/training/components/ExerciseComposition.tsx
  33. 69 0
      frontend/src/training/components/Training.tsx
  34. 30 0
      frontend/src/training/components/TrainingBlock.tsx
  35. 56 0
      frontend/src/training/components/TrainingMeta.tsx
  36. 6 3
      frontend/src/training/components/TrainingType.tsx
  37. 0 0
      frontend/src/training/components/trainingArchive.js
  38. 0 0
      frontend/src/training/components/trainingForm.js
  39. 0 0
      frontend/src/training/components/trainingHint.js
  40. 3 0
      frontend/src/training/index.tsx
  41. 59 0
      frontend/src/training/training.graphql
  42. 50 0
      frontend/src/training/types.ts
  43. 59 0
      frontend/src/training/utils.ts
  44. 18 27
      frontend/src/user/components/LoginForm.tsx
  45. 5 2
      frontend/src/user/components/UserAdmin.tsx
  46. 4 2
      frontend/src/user/components/UserEditForm.tsx

+ 3 - 0
.gitignore

@@ -26,3 +26,6 @@ build
 npm-debug.log*
 yarn-debug.log*
 yarn-error.log*
+
+# media
+media/

+ 18 - 18
backend/index.js

@@ -5,22 +5,22 @@
  * Configure CORS for use with localhost.
  */
 
-require('dotenv').config()
-const { GraphQLServer } = require('graphql-yoga')
-const cookieParser = require('cookie-parser')
-const bodyParser = require('body-parser')
-const { merge } = require('lodash')
-const { db, populateUser } = require('./src/db')
-const user = require('./src/user')
+require("dotenv").config();
+const { GraphQLServer } = require("graphql-yoga");
+const cookieParser = require("cookie-parser");
+const bodyParser = require("body-parser");
+const { merge } = require("lodash");
+const { db, populateUser } = require("./src/db");
+//const user = require("./src/user");
 
-const prismaResolvers = require('./src/resolvers')
+const prismaResolvers = require("./src/resolvers");
 
 const resolvers = merge(
-  prismaResolvers.resolvers,
-  user.resolvers
-)
+  prismaResolvers.resolvers
+  //user.resolvers
+);
 
-const typeDefs = ['./schema.graphql']
+const typeDefs = ["./schema.graphql"];
 
 const server = new GraphQLServer({
   typeDefs,
@@ -30,13 +30,13 @@ const server = new GraphQLServer({
     db,
     debug: true
   })
-})
+});
 
-server.express.use(cookieParser())
-server.express.use(bodyParser.json())
+server.express.use(cookieParser());
+server.express.use(bodyParser.json());
 // server.express.use(quickMiddleware)
-server.express.use(user.authenticate)
-server.express.use(populateUser)
+// server.express.use(user.authenticate);
+server.express.use(populateUser);
 // server.express.use("/static", express.static("static"));
 
 server.start(
@@ -47,4 +47,4 @@ server.start(
     }
   },
   server => console.log(`Server is running on http://localhost:${server.port}`)
-)
+);

+ 0 - 12
backend/src/user/authenticate.js

@@ -1,12 +0,0 @@
-const jwt = require('jsonwebtoken')
-
-const authenticate = (req, res, next) => {
-  const { token } = req.cookies
-  if (token) {
-    const { userId } = jwt.verify(token, process.env.APP_SECRET)
-    req.userId = userId
-  }
-  next()
-}
-
-module.exports = { authenticate }

+ 0 - 7
backend/src/user/index.js

@@ -1,7 +0,0 @@
-const { authenticate } = require('./authenticate')
-const { resolvers } = require('./resolvers')
-
-module.exports = {
-  authenticate,
-  resolvers
-}

+ 0 - 154
backend/src/user/resolvers.js

@@ -1,154 +0,0 @@
-const bcrypt = require('bcryptjs')
-const jwt = require('jsonwebtoken')
-const { promisify } = require('util')
-const randombytes = require('randombytes')
-
-const LoginError = new Error('Login required.')
-const PermissionError = new Error('No permission.')
-
-const Query = {
-  users: async (parent, args, context, info) => {
-    if (!context.request.userId) throw LoginError
-    if (!context.request.user.permissions.find(permission => permission === 'ADMIN')) throw PermissionError
-    return context.db.query.users(args, info)
-  },
-  currentUser: (parent, args, context, info) => {
-    if (!context.request.userId) throw LoginError
-    return context.db.query.user({
-      where: { id: context.request.userId }
-    }, info)
-  }
-}
-
-const Mutation = {
-  createUser: async (parent, { data }, context, info) => {
-    if (!context.request.userId) throw LoginError
-    if (!context.request.user.permissions.find(permission => permission === 'ADMIN')) throw PermissionError
-
-    const email = data.email.toLowerCase()
-    const password = await bcrypt.hash(data.password, 10)
-    return context.db.mutation.createUser({
-      data: {
-        ...data,
-        email,
-        password
-      }
-    }, info)
-  },
-  updateUser: async (parent, { email, data }, context, info) => {
-    if (!context.request.userId) throw LoginError
-    if (!context.request.user.permissions.find(permission => permission === 'ADMIN')) throw PermissionError
-
-    const updateData = { ...data }
-    if (data.email) updateData.email = data.email.toLowerCase()
-    if (data.password) updateData.password = await bcrypt.hash(data.password, 10)
-    return context.db.mutation.updateUser({
-      data: updateData,
-      where: { email }
-    }, info)
-  },
-  deleteUser: (parent, { email }, context, info) => {
-    if (!context.request.userId) throw LoginError
-    if (!context.request.user.permissions.find(permission => permission === 'ADMIN')) throw PermissionError
-
-    return context.db.mutation.deleteUser({ where: { email } })
-  },
-  signup: async (parent, args, ctx, info) => {
-    const email = args.email.toLowerCase()
-    const password = await bcrypt.hash(args.password, 10)
-    const user = await ctx.db.mutation.createUser(
-      {
-        data: {
-          ...args,
-          email,
-          password
-        }
-      },
-      info
-    )
-    const token = jwt.sign({ userId: user.id }, process.env.APP_SECRET)
-    ctx.response.cookie('token', token, {
-      httpOnly: true,
-      maxAge: 24 * 60 * 60 * 1000
-    })
-    return user
-  },
-  login: async (parent, args, context, info) => {
-    const { email, password } = args
-    const user = await context.db.query.user({ where: { email } })
-    if (!user) throw new Error('User not found')
-    const valid = await bcrypt.compare(password, user.password)
-    if (!valid) throw new Error('Invalid password')
-    const token = jwt.sign({ userId: user.id }, process.env.APP_SECRET)
-    context.response.cookie(
-      'token',
-      token,
-      {
-        httpOnly: true,
-        maxAge: 7 * 24 * 3600 * 1000
-      },
-      info
-    )
-    return user
-  },
-  logout: async (parent, args, context, info) => {
-    context.response.clearCookie('token')
-    return 'Logged out.'
-  },
-  requestReset: async (parent, { email }, context, info) => {
-    const user = await context.db.query.user({ where: { email } })
-    if (!user) {
-      return 'Success.'
-    }
-    const randombytesPromisified = promisify(randombytes)
-    const resetToken = (await randombytesPromisified(20)).toString('hex')
-    const resetTokenExpiry = Date.now() + 3600000 // 1 hour from now
-    await context.db.mutation.updateUser({
-      where: { email },
-      data: { resetToken, resetTokenExpiry }
-    })
-    /* await transport.sendMail({
-      from: 'wes@wesbos.com',
-      to: user.email,
-      subject: 'Your Password Reset Token',
-      html: emailTemplate(`Your Password Reset Token is here!
-      \n\n
-      <a href="${process.env
-          .FRONTEND_URL}/reset?resetToken=${resetToken}">Click Here to Reset</a>`)
-    }) */
-    return 'Success.'
-  },
-  resetPassword: async (parent, args, context, info) => {
-    const [user] = await context.db.query.users({
-      where: {
-        resetToken: args.token,
-        resetTokenExpiry_gte: Date.now() - 3600000
-      }
-    })
-    if (!user) {
-      throw Error('Token invalid or expired.')
-    }
-    const password = await bcrypt.hash(args.password, 10)
-    const updatedUser = await context.db.mutation.updateUser({
-      where: { email: user.email },
-      data: {
-        password,
-        resetToken: null,
-        resetTokenExpiry: null
-      }
-    })
-    const token = jwt.sign({ userId: updatedUser.id }, process.env.APP_SECRET)
-    context.response.cookie('token', token, {
-      httpOnly: true,
-      maxAge: 1000 * 60 * 60 * 24 * 365
-    })
-    return updatedUser
-  }
-}
-
-const resolvers = {
-  Query,
-  Mutation
-}
-
-module.exports = { resolvers }

+ 1 - 1
docker-compose.yml

@@ -11,7 +11,7 @@ services:
       - "frontend-nm:/app/node_modules"
       - "/app/.next"
     ports:
-      - "127.0.0.1:8800:3000"
+      - "8800:3000"
     environment:
       - NODE_ENV=development
     depends_on:

+ 0 - 127
frontend/components/training/training.js

@@ -1,127 +0,0 @@
-import theme from '../../styles/theme'
-import PropTypes from 'prop-types'
-
-import TrainingType from './trainingType'
-import TrainingBlock from './trainingBlock'
-
-function calculateRating (ratings) {
-  const numberOfRatings = ratings.length
-  const sumOfRatings = ratings.reduce(
-    (accumulator, rating) => accumulator + rating.value,
-    0
-  )
-  return numberOfRatings ? sumOfRatings / numberOfRatings : '-'
-}
-
-const Training = props => (
-  <article>
-    <h2>{props.title}</h2>
-    <aside>
-      <div id='trainingType'>
-        <span className='caption'>Type: </span>
-        <span className='data'>{props.type.name}</span>
-      </div>
-      <div id='trainingDate'>
-        <span className='caption'>Date: </span>
-        <span className='data'>
-          {new Date(props.trainingDate).toLocaleDateString()}
-        </span>
-      </div>
-      <div id='trainingLocation'>
-        <span className='caption'>Location: </span>
-        <span className='data'>{props.location}</span>
-      </div>
-      <div id='trainingRegistrations'>
-        <span className='caption'>Registrations: </span>
-        <span className='data'> {props.registration.length} </span>
-      </div>
-      <div id='trainingAttendance'>
-        <span className='caption'>Attendance: </span>
-        <span className='data'>{props.attendance}</span>
-      </div>
-      <div id='trainingRatings'>
-        <span className='caption'>Rating: </span>
-        <span className='data'>
-          {calculateRating(props.ratings)} [
-          <a href=''>{props.ratings.length}</a>] Rate it!
-          <a href=''>*</a>
-          <a href=''>*</a>
-          <a href=''>*</a>
-          <a href=''>*</a>
-          <a href=''>*</a>
-        </span>
-      </div>
-      <button>Register now!</button>
-    </aside>
-    <section>
-      <h3>Content</h3>
-      <ol>
-        {props.content
-          .sort(block => block.sequence)
-          .map(block => (
-            <TrainingBlock key={block.id} {...block} />
-          ))}
-      </ol>
-    </section>
-
-    <style jsx>
-      {`
-        article {
-          display: grid;
-          grid-template-areas:
-            'title title'
-            'information placeholder'
-            'content content';
-          grid-template-columns: 1fr 2fr;
-          background-color: rgba(127, 127, 127, 0.5);
-          background-image: url('media/man_working_out.jpg');
-          background-size: auto 400px;
-          background-repeat: no-repeat;
-          margin: 2em 0;
-        }
-
-        article > * {
-          padding: 0.2em 1em;
-        }
-
-        article > h2 {
-          grid-area: title;
-          font-weight: 900;
-          font-size: 120%;
-          background: ${theme.colors.darkerblue};
-          color: ${theme.colors.offWhite};
-        }
-
-        aside {
-          grid-area: information;
-          background: rgba(0, 127, 0, 0.5);
-          padding: 1em 2em;
-          margin: 0 1em;
-          min-height: 350px;
-        }
-
-        section {
-          grid-area: content;
-          padding: 1em 2em;
-          background: rgba(127, 0, 0, 0.5);
-        }
-
-        span.caption {
-          display: none;
-        }
-      `}
-    </style>
-  </article>
-)
-
-Training.propTypes = {
-  title: PropTypes.string.isRequired,
-  type: PropTypes.shape(TrainingType.propTypes).isRequired,
-  content: PropTypes.arrayOf(TrainingBlock.propTypes).isRequired,
-  createdAt: PropTypes.number,
-  trainingDate: PropTypes.number.isRequired,
-  location: PropTypes.string.isRequired,
-  registration: PropTypes
-}
-
-export default Training

+ 0 - 63
frontend/components/training/trainingBlock.js

@@ -1,63 +0,0 @@
-import Track from '../track'
-import Exercise from '../exercise'
-
-const TrainingBlock = props => (
-  <li>
-    <h2>{props.title}</h2>
-    <p>
-      <span className='caption'>Duration: </span>
-      <span className='data'>{props.duration}</span>
-    </p>
-    <p>
-      <span className='caption'>Variation: </span>
-      <span className='data'>{props.variation}</span>
-    </p>
-    <p>
-      <span className='caption'>Description: </span>
-      <span className='data'>{props.description}</span>
-    </p>
-    <p>
-      {props.type ? (
-        <>
-          <span className='caption'>Type: </span>
-          <span className='data'>
-            {props.type.name}
-            <sup>
-              <a title={props.type.description}>[?]</a>
-            </sup>
-          </span>
-        </>
-      ) : null}
-    </p>
-    <section>
-      <h2>Tracks</h2>
-      {props.tracks ? (
-        <ol>
-          {props.tracks.map(track => (
-            <Track key={track.id} {...track} />
-          ))}
-        </ol>
-      ) : null}
-    </section>
-    <section>
-      <h2>Exercises</h2>
-      {props.exercises ? (
-        <ol>
-          {props.exercises.map(exercise => (
-            <Exercise key={exercise.id} {...exercise} />
-          ))}
-        </ol>
-      ) : null}
-    </section>
-
-    <style jsx>
-      {`
-          section {
-            display: grid;
-          }
-        `}
-    </style>
-  </li>
-)
-
-export default TrainingBlock

+ 0 - 128
frontend/initial-data.js

@@ -1,128 +0,0 @@
-const data = {
-  trainings: [
-    {
-      id: 'training0',
-      title: 'Indoor HIIT + Core',
-      type: {
-        id: 'type0',
-        name: 'HIIT',
-        description: 'High Intensity Interval Training'
-      },
-      createdAt: '2019-11-11T21:13:43.284Z',
-      trainingDate: '2019-11-12T11:45:00.000Z',
-      location: 'Yoga Room',
-      registration: [],
-      attendance: 14,
-      ratings: [],
-      published: true,
-      content: [
-        {
-          id: 'block0',
-          sequence: 0,
-          title: 'Warmup',
-          duration: 153,
-          variation: '',
-          format: {
-            id: 'format0',
-            name: 'Sequence',
-            description: 'Simple sequence of exercises'
-          },
-          tracks: [
-            {
-              id: 0,
-              title: "Hold on, I'm coming",
-              artist: 'Sam & Dave',
-              duration: 153,
-              link: 'https://open.spotify.com/track/6PgVDY8GTkxF3GmhVGPzoB'
-            }
-          ],
-          exercises: [
-            {
-              id: 'exercise0',
-              name: 'lateral jump squat',
-              description: '',
-              video: '',
-              targets: ['Glutes'],
-              baseExercise: {
-                id: 'baseExercise0',
-                name: 'squat'
-              }
-            }
-          ],
-          description: 'Warm up everything.'
-        },
-        {
-          id: 'block1',
-          sequence: 1,
-          title: 'Circuit',
-          duration: 720,
-          variation: '',
-          format: {
-            id: 'format1',
-            name: 'Circuit',
-            description: ''
-          },
-          tracks: [
-            {
-              id: 0,
-              artist: 'Daniel Portman',
-              title: "You're Not Alone",
-              duration: 180,
-              link: 'https://open.spotify.com/track/0a4lBQU2DEP6QtisqTldkq'
-            },
-            {
-              id: 1,
-              artist: 'ATFC & David Penn',
-              title: 'Hipcats',
-              duration: 180,
-              link: 'https://open.spotify.com/track/38HvJkH21S2bRciZHohv68'
-            },
-            {
-              id: 2,
-              artist: 'Eli Brown',
-              title: 'In the Dance',
-              duration: 180,
-              link:
-                'https://open.spotify.com/track/0IHFbC9dNZzyZEWy7EZVl1?si=LKAA4MpZRy6oSOk1uZyKBg'
-            },
-            {
-              id: 3,
-              artist: 'Biscits',
-              title: 'Do It Like This',
-              duration: 180,
-              link:
-                'https://open.spotify.com/track/6JR7gYT1P4t1koPPgq4miN?si=1dJpkq5FTHaG36gXsfHeAA'
-            }
-          ],
-          exercises: [
-            {
-              id: 'exercise1',
-              name: 'boxer',
-              description: '',
-              video: '',
-              targets: ['Thighs', 'Glutes'],
-              baseExercise: {
-                id: 'baseExercise1',
-                name: 'on the spot run'
-              }
-            },
-            {
-              id: 'exercise2',
-              name: 'rotational drop squat',
-              description: '',
-              video: '',
-              targets: ['Thighs', 'Glutes'],
-              baseExercise: {
-                id: 'baseExercise0',
-                name: 'squat'
-              }
-            }
-          ]
-        }
-      ]
-    }
-  ],
-  polls: []
-}
-
-export default data

+ 640 - 0
frontend/initial-data.ts

@@ -0,0 +1,640 @@
+import { ITraining } from "./src/training/types";
+
+const data: { trainings: ITraining[]; polls: any } = {
+  trainings: [
+    {
+      id: "training0",
+      title: "Corona 1",
+      type: {
+        id: "type0",
+        name: "HIIT",
+        description: "High Intensity Interval Training"
+      },
+      createdAt: "2020-03-25T21:13:43.284Z",
+      trainingDate: "2020-03-26T10:45:00.000Z",
+      location: "At home",
+      registrations: [],
+      attendance: 0,
+      ratings: [],
+      published: true,
+      blocks: [
+        {
+          id: "block0",
+          sequence: 0,
+          title: "Drop Sets",
+          repetitions: 1,
+          rest: 25,
+          format: {
+            id: "format0",
+            name: "Sequence",
+            description: "Sequence of exercises"
+          },
+          blocks: [
+            {
+              id: "block0",
+              sequence: 0,
+              title: "Lateral move with jumps",
+              repetitions: 1,
+              format: {
+                id: "format0",
+                name: "Drop Sets",
+                description:
+                  "During the exercise, the moves get easier. Keep the heart rate up by increasing the intensity!"
+              },
+              rest: 25,
+              blocks: [
+                {
+                  id: "block0",
+                  duration: 40,
+                  exercises: [
+                    {
+                      id: "exercise0",
+                      name: "Lateral high knee run",
+                      repetitions: 4,
+                      description:
+                        "Jog pulling your knees up high while moving sideways",
+                      videos: ["https://www.youtube.com/watch?v=s5GanRixp6I"],
+                      pictures: [
+                        "https://media1.popsugar-assets.com/files/thumbor/xfgCQbEWOZpPDA_HTMSfgcOnYYE/fit-in/1024x1024/filters:format_auto-!!-:strip_icc-!!-/2015/06/26/981/n/1922729/a7719ba19ea7a1ae_lateral-run-and-hold/i/Tabata-One-Lateral-High-Knee-Run-Hold.jpg"
+                      ],
+                      targets: [],
+                      baseExercise: {
+                        id: "baseExercise1",
+                        name: "Jog on the spot"
+                      }
+                    },
+                    {
+                      id: "exercise1",
+                      name: "Squat",
+                      repetitions: 1,
+                      description:
+                        "Sit down backwards, hip about knee-high. Keep your knees behind the toes.",
+                      videos: [],
+                      pictures: [],
+                      targets: ["Thighs", "Glutes"],
+                      baseExercise: {
+                        id: "baseExercise1",
+                        name: "Squat"
+                      }
+                    },
+                    {
+                      id: "exercise2",
+                      name: "Block jump",
+                      repetitions: 1,
+                      description:
+                        "Jump up. Feet are at least hip-wide, land in a squat. Use your arms to add momentum.",
+                      videos: [],
+                      pictures: [],
+                      targets: ["Thighs", "Glutes"],
+                      baseExercise: {
+                        id: "baseExercise1",
+                        name: "Jump"
+                      }
+                    },
+                    {
+                      id: "exercise3",
+                      name: "Butt kick",
+                      repetitions: 1,
+                      description:
+                        "Jump up. While jumping, try to kick your butt with your heels.",
+                      videos: [],
+                      pictures: [],
+                      targets: [],
+                      baseExercise: {
+                        id: "baseExercise1",
+                        name: "Jump"
+                      }
+                    }
+                  ]
+                },
+                {
+                  id: "block1",
+                  duration: 30,
+                  exercises: [
+                    {
+                      id: "exercise1",
+                      name: "Squat",
+                      repetitions: 1,
+                      description:
+                        "Sit down backwards, hip about knee-high. Keep your knees behind the toes.",
+                      videos: [],
+                      pictures: [],
+                      targets: ["Thighs", "Glutes"],
+                      baseExercise: {
+                        id: "baseExercise1",
+                        name: "Squat"
+                      }
+                    },
+                    {
+                      id: "exercise2",
+                      name: "Block jump",
+                      repetitions: 1,
+                      description:
+                        "Jump up. Feet are at least hip-wide, land in a squat. Use your arms to add momentum.",
+                      videos: [],
+                      pictures: [],
+                      targets: ["Thighs", "Glutes"],
+                      baseExercise: {
+                        id: "baseExercise1",
+                        name: "Jump"
+                      }
+                    },
+                    {
+                      id: "exercise3",
+                      name: "Butt kick",
+                      repetitions: 1,
+                      description:
+                        "Jump up. While jumping, try to kick your butt with your heels.",
+                      videos: [],
+                      pictures: [],
+                      targets: [],
+                      baseExercise: {
+                        id: "baseExercise1",
+                        name: "Jump"
+                      }
+                    }
+                  ]
+                },
+                {
+                  id: "block2",
+                  duration: 20,
+                  exercises: [
+                    {
+                      id: "exercise1",
+                      name: "Squat",
+                      repetitions: 1,
+                      description:
+                        "Sit down backwards, hip about knee-high. Keep your knees behind the toes.",
+                      videos: [],
+                      pictures: [],
+                      targets: ["Thighs", "Glutes"],
+                      baseExercise: {
+                        id: "baseExercise1",
+                        name: "Squat"
+                      }
+                    },
+                    {
+                      id: "exercise2",
+                      name: "Block jump",
+                      repetitions: 1,
+                      description:
+                        "Jump up. Feet are at least hip-wide, land in a squat. Use your arms to add momentum.",
+                      videos: [],
+                      pictures: [],
+                      targets: ["Thighs", "Glutes"],
+                      baseExercise: {
+                        id: "baseExercise1",
+                        name: "Jump"
+                      }
+                    }
+                  ]
+                }
+              ]
+            },
+            {
+              id: "block0",
+              sequence: 0,
+              title: "Pushup with frog-jump and block jump",
+              repetitions: 1,
+              format: {
+                id: "format0",
+                name: "Drop Sets",
+                description:
+                  "During the exercise, the moves get easier. Keep the heart rate up by increasing the intensity!"
+              },
+              rest: 25,
+              blocks: [
+                {
+                  id: "block3",
+                  duration: 40,
+                  exercises: [
+                    {
+                      id: "exercise1",
+                      name: "Squat",
+                      repetitions: 1,
+                      description:
+                        "Sit down backwards, hip about knee-high. Keep your knees behind the toes.",
+                      videos: [],
+                      pictures: [],
+                      targets: ["Thighs", "Glutes"],
+                      baseExercise: {
+                        id: "baseExercise1",
+                        name: "Squat"
+                      }
+                    },
+                    {
+                      id: "exercise4",
+                      name: "Pushup",
+                      repetitions: 1,
+                      description: "",
+                      videos: [],
+                      pictures: [],
+                      targets: ["Chest", "Triceps"],
+                      baseExercise: {
+                        id: "baseExercise1",
+                        name: "Pushup"
+                      }
+                    },
+                    {
+                      id: "exercise2",
+                      name: "Frog jump",
+                      repetitions: 1,
+                      description: "",
+                      videos: [],
+                      pictures: [],
+                      targets: ["Core", "Quads"],
+                      baseExercise: {
+                        id: "baseExercise1",
+                        name: "Mountain climber"
+                      }
+                    },
+                    {
+                      id: "exercise2",
+                      name: "Block jump",
+                      repetitions: 1,
+                      description: "",
+                      videos: [],
+                      pictures: [],
+                      targets: [],
+                      baseExercise: {
+                        id: "baseExercise1",
+                        name: "Jump"
+                      }
+                    }
+                  ]
+                },
+                {
+                  id: "block4",
+                  duration: 30,
+                  exercises: [
+                    {
+                      id: "exercise1",
+                      name: "Squat",
+                      repetitions: 1,
+                      description:
+                        "Sit down backwards, hip about knee-high. Keep your knees behind the toes.",
+                      videos: [],
+                      pictures: [],
+                      targets: ["Thighs", "Glutes"],
+                      baseExercise: {
+                        id: "baseExercise1",
+                        name: "Squat"
+                      }
+                    },
+                    {
+                      id: "exercise4",
+                      name: "Pushup",
+                      repetitions: 1,
+                      description: "",
+                      videos: [],
+                      pictures: [],
+                      targets: ["Chest", "Triceps"],
+                      baseExercise: {
+                        id: "baseExercise1",
+                        name: "Pushup"
+                      }
+                    },
+                    {
+                      id: "exercise2",
+                      name: "Block jump",
+                      repetitions: 1,
+                      description: "",
+                      videos: [],
+                      pictures: [],
+                      targets: [],
+                      baseExercise: {
+                        id: "baseExercise1",
+                        name: "Jump"
+                      }
+                    }
+                  ]
+                },
+                {
+                  id: "block5",
+                  duration: 20,
+                  exercises: [
+                    {
+                      id: "exercise1",
+                      name: "Squat",
+                      repetitions: 1,
+                      description:
+                        "Sit down backwards, hip about knee-high. Keep your knees behind the toes.",
+                      videos: [],
+                      pictures: [],
+                      targets: ["Thighs", "Glutes"],
+                      baseExercise: {
+                        id: "baseExercise1",
+                        name: "Squat"
+                      }
+                    },
+                    {
+                      id: "exercise2",
+                      name: "Block jump",
+                      repetitions: 1,
+                      description: "",
+                      videos: [],
+                      pictures: [],
+                      targets: [],
+                      baseExercise: {
+                        id: "baseExercise1",
+                        name: "Jump"
+                      }
+                    }
+                  ]
+                }
+              ]
+            }
+          ]
+        },
+
+        {
+          id: "block1",
+          sequence: 0,
+          title: "Power Sets with Kicker",
+          repetitions: 2,
+          format: {
+            id: "format0",
+            name: "Sequence",
+            description: "Sequence of exercises"
+          },
+          rest: 25,
+          blocks: [
+            {
+              id: "block0",
+              sequence: 0,
+              title: "Block A",
+              repetitions: 1,
+              format: {
+                id: "format0",
+                name: "Power Set",
+                description:
+                  "Sequence of exercises targeting different regions without rest in between."
+              },
+              rest: 25,
+              blocks: [
+                {
+                  id: "block0",
+                  duration: 25,
+                  exercises: [
+                    {
+                      id: "exercise6",
+                      name: "Donkey kick",
+                      repetitions: 1,
+                      description: "",
+                      videos: [],
+                      pictures: [],
+                      targets: [],
+                      baseExercise: {
+                        id: "baseExercise1",
+                        name: "Mountain climber"
+                      }
+                    },
+                    {
+                      id: "exercise1",
+                      name: "Plank walk",
+                      repetitions: 2,
+                      description: "",
+                      videos: [],
+                      pictures: [],
+                      targets: [],
+                      baseExercise: {
+                        id: "baseExercise1",
+                        name: "Plank"
+                      }
+                    }
+                  ]
+                },
+                {
+                  id: "block1",
+                  duration: 25,
+                  exercises: [
+                    {
+                      id: "exercise1",
+                      name: "Long jump",
+                      repetitions: 1,
+                      description: "",
+                      videos: [],
+                      pictures: [],
+                      targets: [],
+                      baseExercise: {
+                        id: "baseExercise1",
+                        name: "Jump"
+                      }
+                    },
+                    {
+                      id: "exercise2",
+                      name: "180°",
+                      repetitions: 1,
+                      description: "",
+                      videos: [],
+                      pictures: [],
+                      targets: [],
+                      baseExercise: {
+                        id: "baseExercise1",
+                        name: "Jump"
+                      }
+                    }
+                  ]
+                },
+                {
+                  id: "block2",
+                  duration: 10,
+                  exercises: [
+                    {
+                      id: "exercise1",
+                      name: "Burpee",
+                      repetitions: 1,
+                      description: "",
+                      videos: [],
+                      pictures: [],
+                      targets: [],
+                      baseExercise: {
+                        id: "baseExercise1",
+                        name: "Burpee"
+                      }
+                    }
+                  ]
+                }
+              ]
+            },
+
+            {
+              id: "block1",
+              sequence: 0,
+              title: "Block B",
+              repetitions: 1,
+              format: {
+                id: "format0",
+                name: "Power Set",
+                description:
+                  "Sequence of exercises targeting different regions without rest in between."
+              },
+              rest: 25,
+              blocks: [
+                {
+                  id: "block3",
+                  duration: 25,
+                  exercises: [
+                    {
+                      id: "exercise6",
+                      name: "Side climber",
+                      repetitions: 2,
+                      description: "",
+                      videos: [],
+                      pictures: [],
+                      targets: [],
+                      baseExercise: {
+                        id: "baseExercise1",
+                        name: "Mountain climber"
+                      }
+                    },
+                    {
+                      id: "exercise1",
+                      name: "Pushup",
+                      repetitions: 1,
+                      description: "",
+                      videos: [],
+                      pictures: [],
+                      targets: [],
+                      baseExercise: {
+                        id: "baseExercise1",
+                        name: "Plank"
+                      }
+                    }
+                  ]
+                },
+                {
+                  id: "block4",
+                  duration: 25,
+                  exercises: [
+                    {
+                      id: "exercise1",
+                      name: "Power lunge",
+                      repetitions: 1,
+                      description: "",
+                      videos: [],
+                      pictures: [],
+                      targets: [],
+                      baseExercise: {
+                        id: "baseExercise1",
+                        name: "Lunge"
+                      }
+                    }
+                  ]
+                },
+                {
+                  id: "block5",
+                  duration: 10,
+                  exercises: [
+                    {
+                      id: "exercise1",
+                      name: "Burpee",
+                      repetitions: 1,
+                      description: "",
+                      videos: [],
+                      pictures: [],
+                      targets: [],
+                      baseExercise: {
+                        id: "baseExercise1",
+                        name: "Burpee"
+                      }
+                    }
+                  ]
+                }
+              ]
+            },
+
+            {
+              id: "block2",
+              sequence: 0,
+              title: "Block C",
+              repetitions: 1,
+              format: {
+                id: "format0",
+                name: "Power Set",
+                description:
+                  "Sequence of exercises targeting different regions without rest in between."
+              },
+              rest: 25,
+              blocks: [
+                {
+                  id: "block5",
+                  duration: 25,
+                  exercises: [
+                    {
+                      id: "exercise6",
+                      name: "Inch worm",
+                      repetitions: 1,
+                      description: "",
+                      videos: [],
+                      pictures: [],
+                      targets: [],
+                      baseExercise: {
+                        id: "baseExercise1",
+                        name: "Plank"
+                      }
+                    },
+                    {
+                      id: "exercise1",
+                      name: "Block jump",
+                      repetitions: 1,
+                      description: "",
+                      videos: [],
+                      pictures: [],
+                      targets: [],
+                      baseExercise: {
+                        id: "baseExercise1",
+                        name: "Jump"
+                      }
+                    }
+                  ]
+                },
+                {
+                  id: "block5",
+                  duration: 25,
+                  exercises: [
+                    {
+                      id: "exercise1",
+                      name: "Fast feet",
+                      repetitions: 1,
+                      description: "",
+                      videos: [],
+                      pictures: [],
+                      targets: [],
+                      baseExercise: {
+                        id: "baseExercise1",
+                        name: "Jog on the spot"
+                      }
+                    }
+                  ]
+                },
+                {
+                  id: "block5",
+                  duration: 10,
+                  exercises: [
+                    {
+                      id: "exercise1",
+                      name: "Burpee",
+                      repetitions: 1,
+                      description: "",
+                      videos: [],
+                      pictures: [],
+                      targets: [],
+                      baseExercise: {
+                        id: "baseExercise1",
+                        name: "Burpee"
+                      }
+                    }
+                  ]
+                }
+              ]
+            }
+          ]
+        }
+      ]
+    }
+  ],
+  polls: []
+};
+
+export default data;

+ 197 - 0
frontend/package-lock.json

@@ -1314,6 +1314,11 @@
         "@babel/types": "^7.3.0"
       }
     },
+    "@types/howler": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/@types/howler/-/howler-2.1.2.tgz",
+      "integrity": "sha512-gK7snhJcWVvTdiE6cCBAjeZ9od9Q8LmB5lEyB/3n6jMJOLn7EQ8Zvmkz8XPhnN5xB+b7QzCiqxJY3YiHrM3Jpg=="
+    },
     "@types/istanbul-lib-coverage": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz",
@@ -1423,6 +1428,11 @@
         "@types/react-test-renderer": "*"
       }
     },
+    "@types/video.js": {
+      "version": "7.3.4",
+      "resolved": "https://registry.npmjs.org/@types/video.js/-/video.js-7.3.4.tgz",
+      "integrity": "sha512-95Lfh6R2DEs8d4VOhBTLQB8D76DgrknLZW9mhlf/PxM8kqFNvhWhjd5vzuu0W/soUvJZ1BoyldxBtcHvEU0c8g=="
+    },
     "@types/yargs": {
       "version": "13.0.4",
       "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-13.0.4.tgz",
@@ -1447,6 +1457,51 @@
       "resolved": "https://registry.npmjs.org/@types/zen-observable/-/zen-observable-0.8.0.tgz",
       "integrity": "sha512-te5lMAWii1uEJ4FwLjzdlbw3+n0FZNOvFXHxQDKeT0dilh7HOzdMzV2TrJVUzq8ep7J4Na8OUYPRLSQkJHAlrg=="
     },
+    "@videojs/http-streaming": {
+      "version": "1.12.2",
+      "resolved": "https://registry.npmjs.org/@videojs/http-streaming/-/http-streaming-1.12.2.tgz",
+      "integrity": "sha512-/YoApr0Ihaqo2eOLSQA3AdUDgor3VPeRAc+mCFvEB8265OUHxhIBUQ+JoGEKYk6PY2XLK1JqNC0lDuqpr61TPQ==",
+      "requires": {
+        "aes-decrypter": "3.0.0",
+        "global": "^4.3.0",
+        "m3u8-parser": "4.4.0",
+        "mpd-parser": "0.10.0",
+        "mux.js": "5.5.1",
+        "url-toolkit": "^2.1.3",
+        "video.js": "^6.8.0 || ^7.0.0"
+      }
+    },
+    "@videojs/vhs-utils": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/@videojs/vhs-utils/-/vhs-utils-1.3.0.tgz",
+      "integrity": "sha512-oiqXDtHQqDPun7JseWkirUHGrgdYdeF12goUut5z7vwAj4DmUufEPFJ4xK5hYGXGFDyDhk2rSFOR122Ze6qXyQ==",
+      "requires": {
+        "@babel/runtime": "^7.5.5",
+        "global": "^4.3.2",
+        "url-toolkit": "^2.1.6"
+      }
+    },
+    "@videojs/xhr": {
+      "version": "2.5.1",
+      "resolved": "https://registry.npmjs.org/@videojs/xhr/-/xhr-2.5.1.tgz",
+      "integrity": "sha512-wV9nGESHseSK+S9ePEru2+OJZ1jq/ZbbzniGQ4weAmTIepuBMSYPx5zrxxQA0E786T5ykpO8ts+LayV+3/oI2w==",
+      "requires": {
+        "@babel/runtime": "^7.5.5",
+        "global": "~4.4.0",
+        "is-function": "^1.0.1"
+      },
+      "dependencies": {
+        "global": {
+          "version": "4.4.0",
+          "resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz",
+          "integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==",
+          "requires": {
+            "min-document": "^2.19.0",
+            "process": "^0.11.10"
+          }
+        }
+      }
+    },
     "@webassemblyjs/ast": {
       "version": "1.8.5",
       "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.8.5.tgz",
@@ -1681,6 +1736,16 @@
       "integrity": "sha512-7evsyfH1cLOCdAzZAd43Cic04yKydNx0cF+7tiA19p1XnLLPU4dpCQOqpjqwokFe//vS0QqfqqjCS2JkiIs0cA==",
       "dev": true
     },
+    "aes-decrypter": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/aes-decrypter/-/aes-decrypter-3.0.0.tgz",
+      "integrity": "sha1-eEihwUW5/b9Xrj4rWxvHzwZEqPs=",
+      "requires": {
+        "commander": "^2.9.0",
+        "global": "^4.3.2",
+        "pkcs7": "^1.0.2"
+      }
+    },
     "airbnb-prop-types": {
       "version": "2.15.0",
       "resolved": "https://registry.npmjs.org/airbnb-prop-types/-/airbnb-prop-types-2.15.0.tgz",
@@ -3659,6 +3724,11 @@
         "entities": "^1.1.1"
       }
     },
+    "dom-walk": {
+      "version": "0.1.1",
+      "resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.1.tgz",
+      "integrity": "sha1-ZyIm3HTI95mtNTB9+TaroRrNYBg="
+    },
     "domain-browser": {
       "version": "1.2.0",
       "resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-1.2.0.tgz",
@@ -5361,6 +5431,22 @@
       "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
       "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="
     },
+    "global": {
+      "version": "4.3.2",
+      "resolved": "https://registry.npmjs.org/global/-/global-4.3.2.tgz",
+      "integrity": "sha1-52mJJopsdMOJCLEwWxD8DjlOnQ8=",
+      "requires": {
+        "min-document": "^2.19.0",
+        "process": "~0.5.1"
+      },
+      "dependencies": {
+        "process": {
+          "version": "0.5.2",
+          "resolved": "https://registry.npmjs.org/process/-/process-0.5.2.tgz",
+          "integrity": "sha1-FjjYqONML0QKkduVq5rrZ3/Bhc8="
+        }
+      }
+    },
     "globals": {
       "version": "11.12.0",
       "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
@@ -5538,6 +5624,11 @@
       "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.5.tgz",
       "integrity": "sha512-kssjab8CvdXfcXMXVcvsXum4Hwdq9XGtRD3TteMEvEbq0LXyiNQr6AprqKqfeaDXze7SxWvRxdpwE6ku7ikLkg=="
     },
+    "howler": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/howler/-/howler-2.1.3.tgz",
+      "integrity": "sha512-PSGbOi1EYgw80C5UQbxtJM7TmzD+giJunIMBYyH3RVzHZx2fZLYBoes0SpVVHi/SFa1GoNtgXj/j6I7NOKYBxQ=="
+    },
     "html-element-map": {
       "version": "1.2.0",
       "resolved": "https://registry.npmjs.org/html-element-map/-/html-element-map-1.2.0.tgz",
@@ -5758,6 +5849,11 @@
       "resolved": "https://registry.npmjs.org/indexes-of/-/indexes-of-1.0.1.tgz",
       "integrity": "sha1-8w9xbI4r00bHtn0985FVZqfAVgc="
     },
+    "individual": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/individual/-/individual-2.0.0.tgz",
+      "integrity": "sha1-gzsJfa0jKU52EXqY+zjg2a1hu5c="
+    },
     "infer-owner": {
       "version": "1.0.4",
       "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz",
@@ -5926,6 +6022,11 @@
       "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz",
       "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8="
     },
+    "is-function": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/is-function/-/is-function-1.0.1.tgz",
+      "integrity": "sha1-Es+5i2W1fdPRk6MSH19uL0N2ArU="
+    },
     "is-generator-fn": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz",
@@ -6773,6 +6874,11 @@
         "object.assign": "^4.1.0"
       }
     },
+    "keycode": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/keycode/-/keycode-2.2.0.tgz",
+      "integrity": "sha1-PQr1bce4uOXLqNCpfxByBO7CKwQ="
+    },
     "kind-of": {
       "version": "6.0.2",
       "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz",
@@ -6946,6 +7052,14 @@
         "yallist": "^3.0.2"
       }
     },
+    "m3u8-parser": {
+      "version": "4.4.0",
+      "resolved": "https://registry.npmjs.org/m3u8-parser/-/m3u8-parser-4.4.0.tgz",
+      "integrity": "sha512-iH2AygTFILtato+XAgnoPYzLHM4R3DjATj7Ozbk7EHdB2XoLF2oyOUguM7Kc4UVHbQHHL/QPaw98r7PbWzG0gg==",
+      "requires": {
+        "global": "^4.3.2"
+      }
+    },
     "make-dir": {
       "version": "1.3.0",
       "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz",
@@ -7062,6 +7176,14 @@
       "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz",
       "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ=="
     },
+    "min-document": {
+      "version": "2.19.0",
+      "resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz",
+      "integrity": "sha1-e9KC4/WELtKVu3SM3Z8f+iyCRoU=",
+      "requires": {
+        "dom-walk": "^0.1.0"
+      }
+    },
     "mini-css-extract-plugin": {
       "version": "0.8.0",
       "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-0.8.0.tgz",
@@ -7178,6 +7300,17 @@
         "run-queue": "^1.0.3"
       }
     },
+    "mpd-parser": {
+      "version": "0.10.0",
+      "resolved": "https://registry.npmjs.org/mpd-parser/-/mpd-parser-0.10.0.tgz",
+      "integrity": "sha512-eIqkH/2osPr7tIIjhRmDWqm2wdJ7Q8oPfWvdjealzsLV2D2oNe0a0ae2gyYYs1sw5e5hdssDA2V6Sz8MW+Uvvw==",
+      "requires": {
+        "@babel/runtime": "^7.5.5",
+        "@videojs/vhs-utils": "^1.1.0",
+        "global": "^4.3.2",
+        "xmldom": "^0.1.27"
+      }
+    },
     "ms": {
       "version": "2.1.2",
       "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
@@ -7188,6 +7321,11 @@
       "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.7.tgz",
       "integrity": "sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s="
     },
+    "mux.js": {
+      "version": "5.5.1",
+      "resolved": "https://registry.npmjs.org/mux.js/-/mux.js-5.5.1.tgz",
+      "integrity": "sha512-5VmmjADBqS4++8pTI6poSRJ+chHdaoI4XErcQPM5w4QfwaDl+FQlSI0iOgWbYDn6CBCbDRKaSCcEiN2K5aHNGQ=="
+    },
     "nan": {
       "version": "2.14.0",
       "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.0.tgz",
@@ -8031,6 +8169,11 @@
         "node-modules-regexp": "^1.0.0"
       }
     },
+    "pkcs7": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/pkcs7/-/pkcs7-1.0.2.tgz",
+      "integrity": "sha1-ttulJ1KMKUK/wSLOLa/NteWQdOc="
+    },
     "pkg-conf": {
       "version": "3.1.0",
       "resolved": "https://registry.npmjs.org/pkg-conf/-/pkg-conf-3.1.0.tgz",
@@ -9326,6 +9469,14 @@
         "aproba": "^1.1.1"
       }
     },
+    "rust-result": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/rust-result/-/rust-result-1.0.0.tgz",
+      "integrity": "sha1-NMdbLm3Dn+WHXlveyFteD5FTb3I=",
+      "requires": {
+        "individual": "^2.0.0"
+      }
+    },
     "rxjs": {
       "version": "6.5.3",
       "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.3.tgz",
@@ -9339,6 +9490,14 @@
       "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
       "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
     },
+    "safe-json-parse": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/safe-json-parse/-/safe-json-parse-4.0.0.tgz",
+      "integrity": "sha1-fA9XjPzNEtM6ccDgVBPi7KFx6qw=",
+      "requires": {
+        "rust-result": "^1.0.0"
+      }
+    },
     "safe-regex": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz",
@@ -10699,6 +10858,11 @@
         }
       }
     },
+    "url-toolkit": {
+      "version": "2.1.6",
+      "resolved": "https://registry.npmjs.org/url-toolkit/-/url-toolkit-2.1.6.tgz",
+      "integrity": "sha512-UaZ2+50am4HwrV2crR/JAf63Q4VvPYphe63WGeoJxeu8gmOm0qxPt+KsukfakPNrX9aymGNEkkaoICwn+OuvBw=="
+    },
     "use": {
       "version": "3.1.1",
       "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz",
@@ -10774,6 +10938,34 @@
         "extsprintf": "^1.2.0"
       }
     },
+    "video.js": {
+      "version": "7.7.5",
+      "resolved": "https://registry.npmjs.org/video.js/-/video.js-7.7.5.tgz",
+      "integrity": "sha512-+HSp2KNZmGzkmTecXyaXxEGi3F41WAm43PqNp3hWq5wYUQOHwcRu5YhhCz+5q0fDV+SlnFMSSLl/I6QLMlYv/g==",
+      "requires": {
+        "@babel/runtime": "^7.5.5",
+        "@videojs/http-streaming": "1.12.2",
+        "@videojs/xhr": "2.5.1",
+        "global": "4.3.2",
+        "keycode": "^2.2.0",
+        "safe-json-parse": "4.0.0",
+        "videojs-font": "3.2.0",
+        "videojs-vtt.js": "^0.15.2"
+      }
+    },
+    "videojs-font": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/videojs-font/-/videojs-font-3.2.0.tgz",
+      "integrity": "sha512-g8vHMKK2/JGorSfqAZQUmYYNnXmfec4MLhwtEFS+mMs2IDY398GLysy6BH6K+aS1KMNu/xWZ8Sue/X/mdQPliA=="
+    },
+    "videojs-vtt.js": {
+      "version": "0.15.2",
+      "resolved": "https://registry.npmjs.org/videojs-vtt.js/-/videojs-vtt.js-0.15.2.tgz",
+      "integrity": "sha512-kEo4hNMvu+6KhPvVYPKwESruwhHC3oFis133LwhXHO9U7nRnx0RiJYMiqbgwjgazDEXHR6t8oGJiHM6wq5XlAw==",
+      "requires": {
+        "global": "^4.3.1"
+      }
+    },
     "vm-browserify": {
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz",
@@ -11074,6 +11266,11 @@
       "integrity": "sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==",
       "dev": true
     },
+    "xmldom": {
+      "version": "0.1.31",
+      "resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.1.31.tgz",
+      "integrity": "sha512-yS2uJflVQs6n+CyjHoaBmVSqIDevTAWrzMmjG1Gc7h1qQ7uVozNhEPJAwZXWyGQ/Gafo3fCwrcaokezLPupVyQ=="
+    },
     "xtend": {
       "version": "4.0.2",
       "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

+ 4 - 0
frontend/package.json

@@ -13,8 +13,10 @@
   "dependencies": {
     "@apollo/client": "^3.0.0-beta.16",
     "@apollo/react-ssr": "^3.1.3",
+    "@types/howler": "^2.1.2",
     "@types/jest": "^24.0.25",
     "@types/lodash": "^4.14.149",
+    "@types/video.js": "^7.3.4",
     "apollo-boost": "0.4.7",
     "apollo-link": "^1.2.13",
     "apollo-link-error": "^1.1.12",
@@ -22,6 +24,7 @@
     "formik": "^2.1.1",
     "fuse.js": "3.4.5",
     "graphql": "^14.5.8",
+    "howler": "^2.1.3",
     "isomorphic-unfetch": "^3.0.0",
     "lodash": "^4.17.15",
     "next": "9.1.2",
@@ -31,6 +34,7 @@
     "react": "^16.11.0",
     "react-dom": "16.11.0",
     "standard": "^14.3.1",
+    "video.js": "^7.7.5",
     "yup": "^0.27.0"
   },
   "devDependencies": {

+ 17 - 32
frontend/pages/index.js

@@ -1,14 +1,13 @@
-import { useQuery } from '@apollo/client'
-import Link from 'next/link'
+import Link from "next/link";
 
-import Training from '../components/training'
-import initialData from '../initial-data.js'
-import { TRAININGS } from '../lib/graphql'
+import initialData from "../initial-data";
+import { useTrainingsQuery } from "../src/gql";
+import { Training } from "../src/training";
 
-console.log(initialData)
+console.log(initialData);
 
 const Home = () => {
-  const { data, error, loading } = useQuery(TRAININGS)
+  //const { data, error, loading } = useTrainingsQuery();
 
   return (
     <>
@@ -16,36 +15,22 @@ const Home = () => {
         <h1>Stay in Shape with u-fit</h1>
         <p>u-fit is a high intensity interval training offered by u-blox.</p>
         <aside>
-          <div id='trainingTime'>
-            <span className='caption'>When</span>
-            <span className='data'>Tuesdays, 11:45-12:30</span>
+          <div id="trainingTime">
+            <span className="caption">When</span>
+            <span className="data">Tuesdays, 11:45-12:30</span>
           </div>
-          <div id='trainingEquipment'>
-            <span className='caption'>Equipment</span>
-            <span className='data'>Towel, water, optional: yoga mat</span>
+          <div id="trainingEquipment">
+            <span className="caption">Equipment</span>
+            <span className="data">Towel, water, optional: yoga mat</span>
           </div>
         </aside>
       </section>
 
-      <section id='nextTraining'>
-        {error && (<p>Error {error.message}</p>)}
-        {loading && (<p>Loading...</p>)}
-        {data ? (
-          <Training
-            {...{
-              ...data.trainings[data.trainings.length - 1],
-              title: `Your Next Training: ${
-                data.trainings[data.trainings.length - 1].title
-                }`
-            }}
-          />
-        ) : (<p>Nothing found...</p>)}
-        <Link href={{ pathname: '/training' }}>
-          <a>create training...</a>
-        </Link>
+      <section id="nextTraining">
+        <Training training={initialData.trainings[0]} />
       </section>
     </>
-  )
-}
+  );
+};
 
-export default Home
+export default Home;

+ 0 - 5
frontend/pages/signup.js

@@ -1,5 +0,0 @@
-import SignupForm from '../components/user/SignupForm'
-
-const SignupPage = props => <SignupForm />
-
-export default SignupPage

+ 8 - 0
frontend/pages/timer.tsx

@@ -0,0 +1,8 @@
+import initialData from "../initial-data";
+import { Timer } from "../src/timer";
+
+const TimerPage = () => {
+  return <Timer training={initialData.trainings[0]} />;
+};
+
+export default TimerPage;

+ 6 - 6
frontend/pages/training.js

@@ -1,11 +1,11 @@
-import TrainingForm from '../components/trainingForm'
-import { TrainingTypeForm } from '../components/trainingType'
+//import TrainingForm from "../src/training/components/trainingForm";
+//import { TrainingTypeForm } from "../components/trainingType";
 
 const Training = props => (
   <>
-    <TrainingForm />
-    <TrainingTypeForm />
+    <h1>Hey there!</h1>
+    <p>Nothing here...</p>
   </>
-)
+);
 
-export default Training
+export default Training;

+ 14 - 13
frontend/pages/user.tsx

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

TEMPAT SAMPAH
frontend/public/media/Pexels Videos 2786540.mp4


+ 1 - 0
frontend/public/video.css

@@ -0,0 +1 @@
+../node_modules/video.js/dist/video-js.css

+ 9 - 8
frontend/src/app/components/Meta.tsx

@@ -1,14 +1,15 @@
-import Head from 'next/head'
+import Head from "next/head";
 
 const Meta = () => (
   <Head>
-    <meta name='viewport' content='width=device-width, initial-scale=1' />
-    <meta charSet='utf-8' />
-    <link rel='shortcut icon' href='/favicon.ico' />
-    <link rel='stylesheet' type='text/css' href='/nprogress.css' />
-    <link rel='stylesheet' type='text/css' href='/normalize.css' />
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <meta charSet="utf-8" />
+    <link rel="shortcut icon" href="/favicon.ico" />
+    <link rel="stylesheet" type="text/css" href="/nprogress.css" />
+    <link rel="stylesheet" type="text/css" href="/normalize.css" />
+    <link rel="stylesheet" type="text/css" href="/video.css" />
     <title>u-fit</title>
   </Head>
-)
+);
 
-export default Meta
+export default Meta;

+ 8 - 9
frontend/src/app/components/Page.tsx

@@ -1,9 +1,9 @@
-import Head from 'next/head'
-import Header from './Header'
-import Meta from './Meta'
-import Nav from './Nav'
-import Footer from './Footer'
-import GlobalStyle from '../../styles/global'
+import Head from "next/head";
+import Header from "./Header";
+import Meta from "./Meta";
+import Nav from "./Nav";
+import Footer from "./Footer";
+import GlobalStyle from "../../styles/global";
 
 const Page = (props: any) => (
   <>
@@ -13,7 +13,6 @@ const Page = (props: any) => (
     </Head>
 
     <Header />
-    <Nav />
     <main>{props.children}</main>
     <Footer />
 
@@ -21,6 +20,6 @@ const Page = (props: any) => (
       {GlobalStyle}
     </style>
   </>
-)
+);
 
-export default Page
+export default Page;

+ 128 - 0
frontend/src/gql/index.tsx

@@ -1923,6 +1923,48 @@ export type UserWhereInput = {
   ratings_none?: Maybe<RatingWhereInput>,
 };
 
+export type TrainingsQueryVariables = {};
+
+
+export type TrainingsQuery = (
+  { __typename?: 'Query' }
+  & { trainings: Array<Maybe<(
+    { __typename?: 'Training' }
+    & Pick<Training, 'id' | 'published' | 'title' | 'createdAt' | 'trainingDate' | 'location' | 'attendance'>
+    & { type: (
+      { __typename?: 'TrainingType' }
+      & Pick<TrainingType, 'id' | 'name' | 'description'>
+    ), content: Maybe<Array<(
+      { __typename?: 'Block' }
+      & Pick<Block, 'id' | 'sequence' | 'title' | 'duration' | 'variation' | 'description'>
+      & { format: Maybe<(
+        { __typename?: 'Format' }
+        & Pick<Format, 'id' | 'name' | 'description'>
+      )>, tracks: Maybe<Array<(
+        { __typename?: 'Track' }
+        & Pick<Track, 'id' | 'title' | 'artist' | 'duration' | 'link'>
+      )>>, exercises: Maybe<Array<(
+        { __typename?: 'Exercise' }
+        & Pick<Exercise, 'id' | 'name' | 'description' | 'video' | 'targets'>
+        & { baseExercise: (
+          { __typename?: 'BaseExercise' }
+          & Pick<BaseExercise, 'id' | 'name'>
+        ) }
+      )>> }
+    )>>, registration: Maybe<Array<(
+      { __typename?: 'User' }
+      & Pick<User, 'id' | 'name'>
+    )>>, ratings: Maybe<Array<(
+      { __typename?: 'Rating' }
+      & Pick<Rating, 'value' | 'comment'>
+      & { user: (
+        { __typename?: 'User' }
+        & Pick<User, 'id' | 'name'>
+      ) }
+    )>> }
+  )>> }
+);
+
 export type UsersQueryVariables = {};
 
 
@@ -2034,6 +2076,92 @@ export type UserUpdateMutation = (
 );
 
 
+export const TrainingsDocument = gql`
+    query Trainings {
+  trainings {
+    id
+    published
+    title
+    type {
+      id
+      name
+      description
+    }
+    content {
+      id
+      sequence
+      title
+      duration
+      variation
+      format {
+        id
+        name
+        description
+      }
+      tracks {
+        id
+        title
+        artist
+        duration
+        link
+      }
+      exercises {
+        id
+        name
+        description
+        video
+        targets
+        baseExercise {
+          id
+          name
+        }
+      }
+      description
+    }
+    createdAt
+    trainingDate
+    location
+    registration {
+      id
+      name
+    }
+    attendance
+    ratings {
+      user {
+        id
+        name
+      }
+      value
+      comment
+    }
+  }
+}
+    `;
+
+/**
+ * __useTrainingsQuery__
+ *
+ * To run a query within a React component, call `useTrainingsQuery` and pass it any options that fit your needs.
+ * When your component renders, `useTrainingsQuery` returns an object from Apollo Client that contains loading, error, and data properties 
+ * you can use to render your UI.
+ *
+ * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
+ *
+ * @example
+ * const { data, loading, error } = useTrainingsQuery({
+ *   variables: {
+ *   },
+ * });
+ */
+export function useTrainingsQuery(baseOptions?: ApolloReactHooks.QueryHookOptions<TrainingsQuery, TrainingsQueryVariables>) {
+        return ApolloReactHooks.useQuery<TrainingsQuery, TrainingsQueryVariables>(TrainingsDocument, baseOptions);
+      }
+export function useTrainingsLazyQuery(baseOptions?: ApolloReactHooks.LazyQueryHookOptions<TrainingsQuery, TrainingsQueryVariables>) {
+          return ApolloReactHooks.useLazyQuery<TrainingsQuery, TrainingsQueryVariables>(TrainingsDocument, baseOptions);
+        }
+export type TrainingsQueryHookResult = ReturnType<typeof useTrainingsQuery>;
+export type TrainingsLazyQueryHookResult = ReturnType<typeof useTrainingsLazyQuery>;
+export type TrainingsQueryResult = ApolloReactCommon.QueryResult<TrainingsQuery, TrainingsQueryVariables>;
 export const UsersDocument = gql`
     query Users {
   users {

+ 92 - 0
frontend/src/timer/__tests__/utils.test.ts

@@ -0,0 +1,92 @@
+import { getPosition } from "../utils";
+import { IExerciseItem } from "../types";
+
+const exercises: IExerciseItem[] = [
+  { exercise: "exercise1", duration: 1, offset: 0 },
+  { exercise: "exercise2", duration: 2, offset: 1 },
+  { exercise: "exercise3", duration: 3, offset: 3 },
+  { exercise: "exercise4", duration: 4, offset: 6 }
+];
+
+describe("finds the right position in the exercise.", () => {
+  it("can handle negative numbers.", () => {
+    const result = getPosition(exercises, -10);
+    expect(result).toEqual({
+      exerciseIndex: 0,
+      currentTime: -10,
+      exerciseTime: 0
+    });
+  });
+  it("can find the first exercise.", () => {
+    const result = getPosition(exercises, 0);
+    expect(result).toEqual({
+      exerciseIndex: 0,
+      currentTime: 0,
+      exerciseTime: 0
+    });
+  });
+  it("can find the first exercise.", () => {
+    const result = getPosition(exercises, 0.99);
+    expect(result).toEqual({
+      exerciseIndex: 0,
+      currentTime: 1,
+      exerciseTime: 1
+    });
+  });
+  it("can find the second exercise.", () => {
+    const result = getPosition(exercises, 1);
+    expect(result).toEqual({
+      exerciseIndex: 1,
+      currentTime: 1,
+      exerciseTime: 0
+    });
+  });
+  it("can find the second exercise.", () => {
+    const result = getPosition(exercises, 2.99);
+    expect(result).toEqual({
+      exerciseIndex: 1,
+      currentTime: 3,
+      exerciseTime: 2
+    });
+  });
+  it("can find the third exercise.", () => {
+    const result = getPosition(exercises, 3);
+    expect(result).toEqual({
+      exerciseIndex: 2,
+      currentTime: 3,
+      exerciseTime: 0
+    });
+  });
+  it("can find the third exercise.", () => {
+    const result = getPosition(exercises, 5.99);
+    expect(result).toEqual({
+      exerciseIndex: 2,
+      currentTime: 6,
+      exerciseTime: 3
+    });
+  });
+  it("can find the fourth exercise.", () => {
+    const result = getPosition(exercises, 6);
+    expect(result).toEqual({
+      exerciseIndex: 3,
+      currentTime: 6,
+      exerciseTime: 0
+    });
+  });
+  it("can find the fourth exercise.", () => {
+    const result = getPosition(exercises, 9.99);
+    expect(result).toEqual({
+      exerciseIndex: 3,
+      currentTime: 10,
+      exerciseTime: 4
+    });
+  });
+  it("can find the fourth exercise.", () => {
+    const result = getPosition(exercises, 10);
+    expect(result).toEqual({
+      exerciseIndex: null,
+      currentTime: 10,
+      exerciseTime: 0
+    });
+  });
+});

+ 16 - 0
frontend/src/timer/components/AudioPlayer.tsx

@@ -0,0 +1,16 @@
+import { useEffect } from "react";
+import { Howl } from "howler";
+
+const AudioPlayer = ({ setAudio }: { setAudio: any }) => {
+  useEffect(() => {
+    const sound = new Howl({
+      src: ["/media/06_Better_As_One.mp3"]
+    });
+    console.log(sound);
+    setAudio(sound);
+  }, []);
+
+  return <div>Audio Player</div>;
+};
+
+export default AudioPlayer;

+ 111 - 0
frontend/src/timer/components/Countdown.tsx

@@ -0,0 +1,111 @@
+import { formatTime } from "../../training/utils";
+import { useEffect, useState, useRef } from "react";
+import { Howl } from "howler";
+import { describeArc } from "../utils";
+
+interface ICountdown {
+  seconds: number;
+  totalPercent: number;
+  exercisePercent: number;
+}
+
+const Countdown = ({ seconds, totalPercent, exercisePercent }: ICountdown) => {
+  const [color, setColor] = useState("rgba(55,55,55,1)");
+  const [intSeconds, setIntSeconds] = useState(0);
+
+  useEffect(() => {
+    setIntSeconds(Math.floor(seconds));
+  }, [seconds]);
+
+  const rosie = useRef(
+    new Howl({
+      src: ["/media/ROSIE.mp3"],
+      sprite: {
+        five: [10211, 10883 - 10211],
+        four: [11292, 11873 - 11292],
+        three: [12251, 12809 - 12251],
+        two: [13208, 13930 - 13208],
+        one: [14376, 14948 - 14376]
+      }
+    })
+  );
+
+  useEffect(() => {
+    if (intSeconds <= 5) {
+      fadeIn();
+      setTimeout(fadeOut, 300);
+    }
+    if (rosie.current) {
+      if (intSeconds === 5) rosie.current.play("five");
+      else if (intSeconds === 4) rosie.current.play("four");
+      else if (intSeconds === 3) rosie.current.play("three");
+      else if (intSeconds === 2) rosie.current.play("two");
+      else if (intSeconds === 1) rosie.current.play("one");
+    }
+  }, [intSeconds]);
+
+  function fadeIn() {
+    setColor("rgba(127,0,0,1)");
+  }
+  function fadeOut() {
+    setColor("rgba(55,55,55,1)");
+  }
+
+  return (
+    <div id="timer" style={{ color }}>
+      <svg>
+        <text
+          textAnchor="middle"
+          alignmentBaseline="central"
+          y="185"
+          x="150"
+          fill={color}
+        >
+          {formatTime(intSeconds)}
+        </text>
+        <circle
+          cx="150"
+          cy="150"
+          r="140"
+          fill="none"
+          stroke="#aa333344"
+          strokeWidth="5"
+        />
+        <circle
+          cx="150"
+          cy="150"
+          r="130"
+          fill="none"
+          stroke="#3333aa44"
+          strokeWidth="5"
+        />
+        <path
+          d={describeArc(150, 150, 140, 0, totalPercent * 360)}
+          fill="none"
+          stroke="#aa3333ff"
+          strokeWidth="8"
+        />
+        <path
+          d={describeArc(150, 150, 130, 0, exercisePercent * 360)}
+          fill="none"
+          stroke="#3333aaff"
+          strokeWidth="8"
+        />
+      </svg>
+
+      <style jsx>
+        {`
+          :global(#timer svg) {
+            width: 300px;
+            height: 300px;
+            font-size: 100px;
+            font-weight: 900;
+            transition: color 300ms ease-out;
+          }
+        `}
+      </style>
+    </div>
+  );
+};
+
+export default Countdown;

+ 42 - 0
frontend/src/timer/components/Indicator.tsx

@@ -0,0 +1,42 @@
+import { formatTime } from "../../training/utils";
+
+const Indicator = ({
+  time,
+  duration,
+  id
+}: {
+  time: number;
+  duration: number;
+  id: string;
+}) => {
+  const width = `${(100 * (duration - time)) / duration}%`;
+  return (
+    <div className="indicator">
+      {/*<div>{formatTime(duration - time)}</div>*/}
+      <div className="bar" style={{ width }} id={id}></div>
+      <style jsx>
+        {`
+          .indicator {
+            height: 20px;
+            display: grid;
+            grid-template-columns: 1fr;
+          }
+
+          .bar {
+            transition: all ${1000}ms linear;
+          }
+
+          #current-indicator {
+            background-color: blue;
+          }
+
+          #total-indicator {
+            background-color: red;
+          }
+        `}
+      </style>
+    </div>
+  );
+};
+
+export default Indicator;

+ 196 - 0
frontend/src/timer/components/Timer.tsx

@@ -0,0 +1,196 @@
+import { useState, useEffect, useRef } from "react";
+import { VideoJsPlayer } from "video.js";
+
+import { ITraining } from "../../training/types";
+import { getExerciseList, getTrainingTime, getPosition } from "../utils";
+
+import Countdown from "./Countdown";
+import VideoPlayer from "./VideoPlayer";
+import AudioPlayer from "./AudioPlayer";
+import { IExerciseItem } from "../types";
+import { Howl } from "howler";
+
+const Timer = ({ training }: { training: ITraining }) => {
+  const tickPeriod = 100;
+
+  const timeout: {
+    current: ReturnType<typeof setTimeout> | undefined;
+  } = useRef();
+
+  const [time, setTime] = useState(0);
+  const [state, setState] = useState({
+    running: false,
+    startTime: 0,
+    stopTime: 0,
+    timeBuffer: 0,
+    exerciseList: [] as IExerciseItem[],
+    totalTime: 0
+  });
+
+  const rosie = useRef(
+    new Howl({
+      src: ["/media/ROSIE.mp3"],
+      sprite: {
+        ttt: [114, 2956]
+      }
+    })
+  );
+
+  useEffect(() => {
+    //console.log("effect 1");
+    const exerciseList = getExerciseList(training.blocks);
+    const totalTime = getTrainingTime(exerciseList);
+    setState({ ...state, exerciseList, totalTime });
+    rosie.current.play("ttt");
+  }, [training]);
+
+  const trainingOver = time >= state.totalTime;
+  //console.log("is it over?", time, state.totalTime, trainingOver);
+
+  const {
+    currentExercise,
+    previousExercise,
+    nextExercise,
+    exerciseTime
+  } = getPosition(state.exerciseList, time);
+
+  const video: { current: VideoJsPlayer | undefined } = useRef();
+  //const audio: { current: Howl | undefined } = useRef();
+
+  useEffect(() => {
+    //console.log("effect 2", time);
+    if (!state.running) return;
+    timeout.current = setTimeout(tick, tickPeriod - state.timeBuffer);
+    setState({ ...state, timeBuffer: 0 });
+  }, [time, state.running]);
+
+  function tick() {
+    setTime((Date.now() - state.startTime) / 1000);
+
+    if (trainingOver) stopTimer();
+  }
+
+  function startTimer() {
+    if (trainingOver) return;
+    setState({
+      ...state,
+      running: true,
+      startTime: state.startTime + Date.now() - state.stopTime
+    });
+    if (video.current) video.current.play();
+    //if (audio.current) audio.current.play();
+    //console.log("Timer started.");
+  }
+
+  function stopTimer() {
+    if (timeout.current) clearTimeout(timeout.current);
+
+    setState({
+      ...state,
+      running: false,
+      stopTime: Date.now(),
+      timeBuffer: Date.now() - state.startTime
+    });
+
+    if (video.current) video.current.pause();
+    //if (audio.current) audio.current.pause();
+    //console.log("stopped");
+  }
+
+  function forward() {
+    if (nextExercise) {
+      setState({
+        ...state,
+        startTime: state.startTime + 1000 * (time - nextExercise.offset)
+      });
+      setTime((Date.now() - state.startTime) / 1000);
+    }
+  }
+
+  function back() {
+    if (previousExercise) {
+      setState({
+        ...state,
+        startTime: state.startTime + 1000 * (time - previousExercise.offset)
+      });
+      setTime((Date.now() - state.startTime) / 1000);
+    }
+  }
+
+  //console.log("current state:", currentExercise, state);
+
+  return (
+    <>
+      <div>
+        {/*<label htmlFor="rest">Rest</label>
+        <input type="number" min="25" max="60" step="5" defaultValue="25" />*/}
+        <button onClick={back}>back</button>
+        <button
+          onClick={state.running ? () => stopTimer() : () => startTimer()}
+        >
+          {state.running ? "stop" : "start"}
+        </button>
+        <button onClick={forward}>forward</button>
+      </div>
+      <div className="content">
+        <Countdown
+          seconds={
+            currentExercise ? currentExercise.duration - exerciseTime : 0
+          }
+          totalPercent={time / state.totalTime}
+          exercisePercent={
+            exerciseTime / (currentExercise ? currentExercise.duration : 1)
+          }
+        />
+        <div className="header">current exercise</div>
+        <div className="exercise">
+          {currentExercise ? currentExercise.exercise : "😎"}
+        </div>
+        <div className="header">next up</div>
+        <div className="exercise">
+          {nextExercise ? nextExercise.exercise : "😎"}
+        </div>
+        <VideoPlayer
+          getVideoHandle={(videoHandle: VideoJsPlayer) =>
+            (video.current = videoHandle)
+          }
+        />
+      </div>
+
+      <style jsx>{`
+        .content {
+          text-align: center;
+        }
+
+        .header {
+          margin-top: 0.4em;
+          font-size: 90%;
+        }
+
+        .exercise {
+          font-size: 110%;
+          font-weight: 900;
+        }
+
+        :global(.content .video-js) {
+          margin: 20px auto;
+        }
+
+        @media (min-width: 700px) {
+          .content {
+            text-align: center;
+            display: grid;
+            grid-auto-flow: dense;
+            grid-template-columns: 1fr auto;
+          }
+          .header,
+          .exercise {
+            grid-column: 1/2;
+          }
+        }
+      `}</style>
+    </>
+  );
+};
+
+export default Timer;

+ 30 - 0
frontend/src/timer/components/VideoPlayer.tsx

@@ -0,0 +1,30 @@
+import { useRef, useEffect } from "react";
+import videojs, { VideoJsPlayer } from "video.js";
+
+const VideoPlayer = ({
+  getVideoHandle
+}: {
+  getVideoHandle: (videoHandle: VideoJsPlayer) => void;
+}) => {
+  const videoPlayerRef = useRef();
+  useEffect(() => {
+    const video = videojs(videoPlayerRef.current, {
+      //sources: [{ src: "/media/tomi.webm" }],
+      sources: [{ src: "/media/Pexels_Videos_2786540.mp4" }],
+      width: 320,
+      loop: true
+    });
+    getVideoHandle(video);
+    return () => {
+      if (video) video.dispose();
+    };
+  }, []);
+
+  return (
+    <div data-vjs-player>
+      <video ref={videoPlayerRef as any} className="video-js" />
+    </div>
+  );
+};
+
+export default VideoPlayer;

+ 3 - 0
frontend/src/timer/index.ts

@@ -0,0 +1,3 @@
+import Timer from "./components/Timer";
+
+export { Timer };

+ 5 - 0
frontend/src/timer/types.ts

@@ -0,0 +1,5 @@
+export interface IExerciseItem {
+  exercise: string;
+  duration: number;
+  offset: number;
+}

+ 116 - 0
frontend/src/timer/utils.ts

@@ -0,0 +1,116 @@
+import { IBlock } from "../training/types";
+import { calculateDuration } from "../training/utils";
+import { IExerciseItem } from "./types";
+
+/**
+ * Find the right exercise given a certain time.
+ * @param exerciseList
+ * @param time
+ */
+export function getPosition(exerciseList: IExerciseItem[], time: number) {
+  const index = exerciseList.findIndex(
+    exercise => time < exercise.offset + exercise.duration
+  );
+
+  const previousExercise = index > 0 ? exerciseList[index - 1] : null;
+  const currentExercise = index >= 0 ? exerciseList[index] : null;
+  const nextExercise =
+    index < exerciseList.length - 2 ? exerciseList[index + 1] : null;
+  const values = {
+    currentExercise,
+    nextExercise,
+    previousExercise,
+    exerciseTime: 0
+  };
+  if (currentExercise !== null) {
+    values.exerciseTime = Math.max(time - currentExercise.offset, 0);
+  }
+  return values;
+}
+
+/**
+ * Takes a (nested) block of exercises and returns a flat array of exercises.
+ * @param blocks
+ * @param initialOffset - used for recursive application
+ */
+export function getExerciseList(
+  blocks: IBlock[],
+  initialOffset = 0
+): IExerciseItem[] {
+  let offset = initialOffset;
+  return blocks
+    .map(block => {
+      if (block.blocks) {
+        const subBlocks = getExerciseList(block.blocks, offset);
+        const lastItem = subBlocks[subBlocks.length - 1];
+        offset = lastItem.offset + lastItem.duration;
+        if (block.rest) {
+          if (lastItem.exercise === "Rest") {
+            lastItem.duration += block.rest;
+            offset += block.rest;
+          } else {
+            subBlocks.push({
+              exercise: "Rest",
+              duration: block.rest,
+              offset
+            });
+            offset += block.rest;
+          }
+        }
+        return subBlocks;
+      } else if (block.exercises) {
+        const newItem = {
+          exercise: block.exercises
+            .map(exercise =>
+              exercise.repetitions > 1
+                ? `${exercise.repetitions}x ${exercise.name}`
+                : exercise.name
+            )
+            .join(" - "),
+          duration: calculateDuration(block),
+          offset
+        };
+        offset += calculateDuration(block);
+        return newItem;
+      }
+    })
+    .flat();
+}
+
+export function getTrainingTime(exercises: IExerciseItem[]) {
+  return exercises.reduce(
+    (accumulator, exercise) => accumulator + exercise.duration,
+    0
+  );
+}
+
+export function polarToCartesian(
+  centerX: number,
+  centerY: number,
+  radius: number,
+  angleInDegrees: number
+) {
+  const angleInRadians = ((angleInDegrees - 90) * Math.PI) / 180.0;
+
+  return {
+    x: centerX + radius * Math.cos(angleInRadians),
+    y: centerY + radius * Math.sin(angleInRadians)
+  };
+}
+
+export function describeArc(
+  x: number,
+  y: number,
+  radius: number,
+  startAngle: number,
+  endAngle: number
+) {
+  const start = polarToCartesian(x, y, radius, endAngle);
+  const end = polarToCartesian(x, y, radius, startAngle);
+
+  const largeArcFlag = endAngle - startAngle <= 180 ? "0" : "1";
+
+  const arcString = `M ${start.x} ${start.y} A ${radius} ${radius} 0 ${largeArcFlag} 0 ${end.x} ${end.y}`;
+
+  return arcString;
+}

+ 32 - 0
frontend/src/training/components/ExerciseComposition.tsx

@@ -0,0 +1,32 @@
+import { formatTime, printExercises } from "../utils";
+import { IExercise } from "../types";
+
+export interface IExerciseComposition {
+  exercises: IExercise[];
+  duration: number;
+}
+
+const ExerciseComposition = ({ exercises, duration }: IExerciseComposition) => {
+  const exerciseString = printExercises(exercises);
+
+  return (
+    <div className="exercise-composition">
+      <span>{exerciseString}</span>
+      <span className="exercise-time">{formatTime(duration)}</span>
+
+      <style jsx>
+        {`
+          .exercise-composition {
+            display: grid;
+            grid-template-columns: 5fr 1fr;
+          }
+          .exercise-composition .exercise-time {
+            text-align: right;
+          }
+        `}
+      </style>
+    </div>
+  );
+};
+
+export default ExerciseComposition;

+ 69 - 0
frontend/src/training/components/Training.tsx

@@ -0,0 +1,69 @@
+import theme from "../../styles/theme";
+
+import TrainingBlock from "./TrainingBlock";
+import Link from "next/link";
+import { ITraining } from "../types";
+import TrainingMeta from "./TrainingMeta";
+
+const Training = ({ training }: { training: ITraining }) => {
+  return (
+    <article>
+      <h2>{training.title}</h2>
+
+      <TrainingMeta training={training} />
+
+      <section>
+        <h2>Program</h2>
+        <Link href="/timer">
+          <a>Start Timer</a>
+        </Link>
+        {training.blocks &&
+          training.blocks
+            .sort(block => block.sequence || 0)
+            .map(block => <TrainingBlock key={block.id} block={block} />)}
+      </section>
+
+      <style jsx>
+        {`
+          article {
+            display: grid;
+            grid-template-areas:
+              "title title"
+              "information placeholder"
+              "content content";
+            grid-template-columns: 1fr 2fr;
+            background-color: rgba(127, 127, 127, 0.5);
+            background-image: url("media/man_working_out.jpg");
+            background-size: auto 400px;
+            background-repeat: no-repeat;
+            margin: 2em 0;
+          }
+
+          article > * {
+            padding: 0.2em 1em;
+            margin: 0;
+          }
+
+          article > h2 {
+            grid-area: title;
+            font-weight: 900;
+            font-size: 120%;
+            background: ${theme.colors.darkerblue};
+            color: ${theme.colors.offWhite};
+          }
+
+          section {
+            grid-area: content;
+            padding: 1em 2em;
+            background: rgba(127, 0, 0, 0.2);
+          }
+
+          span.caption {
+          }
+        `}
+      </style>
+    </article>
+  );
+};
+
+export default Training;

+ 30 - 0
frontend/src/training/components/TrainingBlock.tsx

@@ -0,0 +1,30 @@
+import ExerciseComposition from "./ExerciseComposition";
+import { calculateDuration, formatTime } from "../utils";
+import { IBlock } from "../types";
+
+const TrainingBlock = ({ block }: { block: IBlock }) => {
+  const duration = calculateDuration(block);
+  return (
+    <div>
+      {block.title && (
+        <h3>
+          {block.title} ({formatTime(duration)})
+        </h3>
+      )}
+      {block.blocks &&
+        block.blocks.map(block => <TrainingBlock block={block} />)}
+      {block.exercises && (
+        <ExerciseComposition exercises={block.exercises} duration={duration} />
+      )}
+
+      <style jsx>
+        {`
+          section {
+            display: grid;
+          }
+        `}
+      </style>
+    </div>
+  );
+};
+export default TrainingBlock;

+ 56 - 0
frontend/src/training/components/TrainingMeta.tsx

@@ -0,0 +1,56 @@
+import { ITraining } from "../types";
+import { calculateRating } from "../utils";
+
+const TrainingMeta = ({ training }: { training: ITraining }) => {
+  return (
+    <aside>
+      <div id="trainingType">
+        <span className="caption">Type: </span>
+        <span className="data">{training.type.name}</span>
+      </div>
+      <div id="trainingDate">
+        <span className="caption">Date: </span>
+        <span className="data">
+          {new Date(training.trainingDate).toLocaleString()}
+        </span>
+      </div>
+      <div id="trainingLocation">
+        <span className="caption">Location: </span>
+        <span className="data">{training.location}</span>
+      </div>
+      <div id="trainingRegistrations">
+        <span className="caption">Registrations: </span>
+        <span className="data"> {training.registrations.length} </span>
+      </div>
+      <div id="trainingAttendance">
+        <span className="caption">Attendance: </span>
+        <span className="data">{training.attendance}</span>
+      </div>
+      <div id="trainingRatings">
+        <span className="caption">Rating: </span>
+        <span className="data">
+          {calculateRating(training.ratings)} [
+          <a href="">{training.ratings.length}</a>] Rate it!
+          <a href="">*</a>
+          <a href="">*</a>
+          <a href="">*</a>
+          <a href="">*</a>
+          <a href="">*</a>
+        </span>
+      </div>
+      <button>Register now!</button>
+
+      <style jsx>{`
+        aside {
+          grid-area: information;
+          background: rgba(200, 200, 200, 0.8);
+          min-height: 370px;
+          padding: 0.2em 1em;
+          margin: 0;
+        }
+      `}</style>
+    </aside>
+  );
+};
+
+export default TrainingMeta;

+ 6 - 3
frontend/components/training/trainingType.js → frontend/src/training/components/TrainingType.tsx

@@ -1,10 +1,10 @@
-import { Query, Mutation } from 'react-apollo'
+/*import { Query, Mutation } from 'react-apollo'
 import { adopt } from 'react-adopt'
 import { Formik, Form } from 'formik'
 
 import { TextInput } from '../../lib/forms'
 import { CREATE_TRAINING_TYPE, TRAINING_TYPES } from '../../lib/graphql'
-import Overlay from '../overlay'
+import Overlay from '../../../components/overlay'
 
 const TrainingTypeInput = props => {
   const [displayForm, setDisplayForm] = React.useState(false)
@@ -93,4 +93,7 @@ const TrainingTypeForm = props => (
 )
 
 export { TrainingTypeForm }
-export default TrainingTypeInput
+*/
+
+const TrainingTypeInput = () => <p>nothing here.</p>;
+export default TrainingTypeInput;

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


+ 0 - 0
frontend/components/training/trainingForm.js → frontend/src/training/components/trainingForm.js


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


+ 3 - 0
frontend/src/training/index.tsx

@@ -0,0 +1,3 @@
+import Training from "./components/Training";
+
+export { Training };

+ 59 - 0
frontend/src/training/training.graphql

@@ -0,0 +1,59 @@
+query Trainings {
+  trainings {
+    id
+    published
+    title
+    type {
+      id
+      name
+      description
+    }
+    content {
+      id
+      sequence
+      title
+      duration
+      variation
+      format {
+        id
+        name
+        description
+      }
+      tracks {
+        id
+        title
+        artist
+        duration
+        link
+      }
+      exercises {
+        id
+        name
+        description
+        video
+        targets
+        baseExercise {
+          id
+          name
+        }
+      }
+      description
+    }
+    createdAt
+    trainingDate
+    location
+    registration {
+      id
+      name
+    }
+    attendance
+    ratings {
+      user {
+        id
+        name
+      }
+      value
+      comment
+    }
+  }
+}

+ 50 - 0
frontend/src/training/types.ts

@@ -0,0 +1,50 @@
+export interface ITraining {
+  id: string;
+  title: string;
+  type: {
+    id: string;
+    name: string;
+    description: string;
+  };
+  createdAt: string;
+  trainingDate: string;
+  location: string;
+  registrations: string[];
+  attendance: number;
+  ratings: IRating[];
+  published: boolean;
+  blocks: IBlock[];
+}
+
+export interface IBlock {
+  id: string;
+  sequence?: number;
+  title?: string;
+  comment?: string;
+  duration?: number;
+  repetitions?: number;
+  rest?: number;
+  format?: IFormat;
+  blocks?: IBlock[];
+  exercises?: IExercise[];
+}
+
+export interface IFormat {}
+
+export interface IExercise {
+  id: string;
+  name: string;
+  description: string;
+  repetitions: number;
+  videos: string[];
+  pictures: string[];
+  targets: string[];
+  baseExercise: {
+    id: string;
+    name: string;
+  };
+}
+
+export interface IRating {
+  value: number;
+}

+ 59 - 0
frontend/src/training/utils.ts

@@ -0,0 +1,59 @@
+import { IBlock, IExercise, IRating } from "./types";
+
+/**
+ * Takes a block of exercises and calculates the duration in seconds.
+ * @param block
+ */
+export function calculateDuration(block: IBlock): number {
+  if (block.duration) return block.duration;
+  const repetitions = block.repetitions || 1;
+  const rest = block.rest || 0;
+  if (block.blocks) {
+    const subblockDuration = block.blocks.reduce(
+      (accumulator, block) =>
+        accumulator + (block.duration || calculateDuration(block)),
+      0
+    );
+    return repetitions * (subblockDuration + rest);
+  } else {
+    return 0;
+  }
+}
+
+/**
+ * Formats a time in seconds into 0:00
+ * @param seconds
+ */
+export function formatTime(seconds: number) {
+  return `${Math.floor(seconds / 60)}:${(seconds % 60)
+    .toString()
+    .padStart(2, "0")}`;
+}
+
+/**
+ * Takes an array of exercises and formats them into
+ * 4x Exercise 1 - Exercise 2 - 2x Exercise 3
+ * @param exercises
+ */
+export function printExercises(exercises: IExercise[]) {
+  return exercises
+    .map(exercise =>
+      exercise.repetitions > 1
+        ? `${exercise.repetitions}x ${exercise.name}`
+        : exercise.name
+    )
+    .join(" - ");
+}
+
+/**
+ * Takes an array of rating and calculates the average rating
+ * @param ratings
+ */
+export function calculateRating(ratings: IRating[]) {
+  const numberOfRatings = ratings.length;
+  const sumOfRatings = ratings.reduce(
+    (accumulator, rating) => accumulator + rating.value,
+    0
+  );
+  return numberOfRatings ? sumOfRatings / numberOfRatings : "-";
+}

+ 18 - 27
frontend/src/user/components/LoginForm.tsx

@@ -1,34 +1,25 @@
-import { Formik, Form } from 'formik'
-import { useUserLoginMutation, CurrentUserDocument } from '../../gql'
-import { TextInput } from '../../form'
+import { useUserLoginMutation, CurrentUserDocument } from "../../gql";
+import { TextInput } from "../../form";
 
 const initialValues = {
-  email: 'tomislav.cvetic@u-blox.com',
-  password: '1234'
-}
+  email: "tomislav.cvetic@u-blox.com",
+  password: "1234"
+};
 
 const LoginForm = () => {
-
-  const [login, { loading, error, data }] = useUserLoginMutation({ refetchQueries: CurrentUserDocument })
-  console.log('LoginForm', loading, error, data)
+  const [login, { loading, error, data }] = useUserLoginMutation();
+  console.log("LoginForm", loading, error, data);
 
   return (
-    <Formik
-      initialValues={initialValues}
-      onSubmit={values => {
-        login({ variables: values })
-      }}
-    >
-      {({ values, setValues }) => (
-        <Form>
-          <TextInput label='Email' name='email' type='text' placeholder='Email' />
-          <TextInput label='Password' name='password' type='password' />
-          <button type='submit' disabled={loading}>Login!</button>
-          {error && <div className='error'>{error.message}</div>}
-        </Form>
-      )}
-    </Formik>
-  )
-}
+    <form>
+      <TextInput label="Email" name="email" type="text" placeholder="Email" />
+      <TextInput label="Password" name="password" type="password" />
+      <button type="submit" disabled={loading}>
+        Login!
+      </button>
+      {error && <div className="error">{error.message}</div>}
+    </form>
+  );
+};
 
-export default LoginForm
+export default LoginForm;

+ 5 - 2
frontend/src/user/components/UserAdmin.tsx

@@ -1,4 +1,4 @@
-import { useEffect, useState } from 'react'
+/*import { useEffect, useState } from 'react'
 import { Formik, Form, useField } from 'formik'
 import { useUsersQuery, useUserDeleteMutation, useUserUpdateMutation, UsersQuery, Permission, User, UsersQueryResult } from '../../gql'
 import { TextInput, Checkbox } from '../../form'
@@ -132,5 +132,8 @@ const UserAdmin = () => {
     )
   }
 }
+*/
 
-export default UserAdmin
+const UserAdmin = () => <p>nothing here.</p>;
+
+export default UserAdmin;

+ 4 - 2
frontend/src/user/components/UserEditForm.tsx

@@ -1,4 +1,4 @@
-import { useFormHandler, TextInput } from "../../form"
+/*import { useFormHandler, TextInput } from "../../form"
 
 import { UserProps } from "../props"
 import { userValidation } from "../validation"
@@ -27,5 +27,7 @@ const UserEditForm = ({ user }: UserProps) => {
     </form>
   )
 }
+*/
 
-export default UserEditForm
+const UserEditForm = () => <p>nothing here.</p>;
+export default UserEditForm;