Răsfoiți Sursa

working on more selectors.

Tomi Cvetic 4 ani în urmă
părinte
comite
6dac309536

+ 13 - 0
backend/schema.graphql

@@ -60,6 +60,16 @@ type Query {
     last: Int
   ): [File!]!
   file(where: FileWhereUniqueInput!): File!
+  videos(
+    where: VideoWhereInput
+    orderBy: VideoOrderByInput
+    skip: Int
+    after: String
+    before: String
+    first: Int
+    last: Int
+  ): [Video!]!
+  video(where: VideoWhereUniqueInput!): Video!
 
   # User module
   currentUser: User!
@@ -128,6 +138,9 @@ type Mutation {
   ): File!
   updateFile(where: FileWhereUniqueInput!, data: FileUpdateInput!): File!
   deleteFile(id: ID!): File!
+  createVideo(data: VideoCreateInput!): Video!
+  updateVideo(where: VideoWhereUniqueInput!, data: VideoUpdateInput!): Video!
+  deleteVideo(id: ID!): Video!
 
   # User module
   createUser(data: UserCreateInput!): User!

+ 12 - 0
backend/src/file/resolvers.ts

@@ -55,6 +55,18 @@ export const resolvers: IResolvers = {
 
       return context.db.mutation.deleteFile({ where: { id } })
     },
+    createVideo: (parent, args, context, info) => {
+      checkPermission(context, 'ADMIN')
+      return context.db.mutation.createVideo(args, info)
+    },
+    updateVideo: (parent, args, context, info) => {
+      checkPermission(context, 'ADMIN')
+      return context.db.mutation.updateVideo(args, info)
+    },
+    deleteVideo: (parent, args, context, info) => {
+      checkPermission(context, 'ADMIN')
+      return context.db.mutation.deleteVideo({ where: { args } }, info)
+    },
   },
 }
 

+ 78 - 28
backend/src/gql/resolvers.ts

@@ -2272,6 +2272,9 @@ export type Mutation = {
   uploadFile: File,
   updateFile: File,
   deleteFile: File,
+  createVideo: Video,
+  updateVideo: Video,
+  deleteVideo: Video,
   createUser: User,
   updateUser: User,
   deleteUser: User,
@@ -2314,6 +2317,22 @@ export type MutationDeleteFileArgs = {
 };
 
 
+export type MutationCreateVideoArgs = {
+  data: VideoCreateInput
+};
+
+
+export type MutationUpdateVideoArgs = {
+  where: VideoWhereUniqueInput,
+  data: VideoUpdateInput
+};
+
+
+export type MutationDeleteVideoArgs = {
+  id: Scalars['ID']
+};
+
+
 export type MutationCreateUserArgs = {
   data: UserCreateInput
 };
@@ -2682,6 +2701,8 @@ export type Query = {
   fsFiles: Array<FsFile>,
   files: Array<File>,
   file: File,
+  videos: Array<Video>,
+  video: Video,
   currentUser: User,
   user?: Maybe<User>,
   users: Array<User>,
@@ -2752,6 +2773,22 @@ export type QueryFileArgs = {
 };
 
 
+export type QueryVideosArgs = {
+  where?: Maybe<VideoWhereInput>,
+  orderBy?: Maybe<VideoOrderByInput>,
+  skip?: Maybe<Scalars['Int']>,
+  after?: Maybe<Scalars['String']>,
+  before?: Maybe<Scalars['String']>,
+  first?: Maybe<Scalars['Int']>,
+  last?: Maybe<Scalars['Int']>
+};
+
+
+export type QueryVideoArgs = {
+  where: VideoWhereUniqueInput
+};
+
+
 export type QueryUserArgs = {
   where: UserWhereUniqueInput
 };
@@ -4506,6 +4543,12 @@ export type VideoUpdateDataInput = {
   link?: Maybe<LinkUpdateOneInput>,
 };
 
+export type VideoUpdateInput = {
+  order?: Maybe<Scalars['Int']>,
+  file?: Maybe<FileUpdateOneInput>,
+  link?: Maybe<LinkUpdateOneInput>,
+};
+
 export type VideoUpdateManyDataInput = {
   order?: Maybe<Scalars['Int']>,
 };
@@ -4755,6 +4798,7 @@ export type ResolversTypes = {
   FsFile: ResolverTypeWrapper<FsFile>,
   FileOrderByInput: FileOrderByInput,
   FileWhereUniqueInput: FileWhereUniqueInput,
+  VideoWhereUniqueInput: VideoWhereUniqueInput,
   UserWhereUniqueInput: UserWhereUniqueInput,
   TrainingTypeWhereUniqueInput: TrainingTypeWhereUniqueInput,
   TrainingTypeOrderByInput: TrainingTypeOrderByInput,
@@ -4795,6 +4839,20 @@ export type ResolversTypes = {
   RatingUpdateManyDataInput: RatingUpdateManyDataInput,
   RatingUpsertWithWhereUniqueWithoutUserInput: RatingUpsertWithWhereUniqueWithoutUserInput,
   UserUpsertNestedInput: UserUpsertNestedInput,
+  VideoCreateInput: VideoCreateInput,
+  FileCreateOneInput: FileCreateOneInput,
+  FileCreateInput: FileCreateInput,
+  UserCreateOneInput: UserCreateOneInput,
+  LinkCreateOneInput: LinkCreateOneInput,
+  LinkCreateInput: LinkCreateInput,
+  LinkWhereUniqueInput: LinkWhereUniqueInput,
+  VideoUpdateInput: VideoUpdateInput,
+  FileUpdateOneInput: FileUpdateOneInput,
+  FileUpdateDataInput: FileUpdateDataInput,
+  FileUpsertNestedInput: FileUpsertNestedInput,
+  LinkUpdateOneInput: LinkUpdateOneInput,
+  LinkUpdateDataInput: LinkUpdateDataInput,
+  LinkUpsertNestedInput: LinkUpsertNestedInput,
   UserUpdateInput: UserUpdateInput,
   TrainingTypeCreateOneInput: TrainingTypeCreateOneInput,
   TrainingTypeCreateInput: TrainingTypeCreateInput,
@@ -4806,16 +4864,8 @@ export type ResolversTypes = {
   FormatCreateInput: FormatCreateInput,
   TrackCreateManyInput: TrackCreateManyInput,
   TrackCreateInput: TrackCreateInput,
-  FileCreateOneInput: FileCreateOneInput,
-  FileCreateInput: FileCreateInput,
-  UserCreateOneInput: UserCreateOneInput,
-  LinkCreateOneInput: LinkCreateOneInput,
-  LinkCreateInput: LinkCreateInput,
-  LinkWhereUniqueInput: LinkWhereUniqueInput,
   TrackWhereUniqueInput: TrackWhereUniqueInput,
   VideoCreateManyInput: VideoCreateManyInput,
-  VideoCreateInput: VideoCreateInput,
-  VideoWhereUniqueInput: VideoWhereUniqueInput,
   PictureCreateManyInput: PictureCreateManyInput,
   PictureCreateInput: PictureCreateInput,
   PictureWhereUniqueInput: PictureWhereUniqueInput,
@@ -4869,12 +4919,6 @@ export type ResolversTypes = {
   TrackUpdateManyInput: TrackUpdateManyInput,
   TrackUpdateWithWhereUniqueNestedInput: TrackUpdateWithWhereUniqueNestedInput,
   TrackUpdateDataInput: TrackUpdateDataInput,
-  FileUpdateOneInput: FileUpdateOneInput,
-  FileUpdateDataInput: FileUpdateDataInput,
-  FileUpsertNestedInput: FileUpsertNestedInput,
-  LinkUpdateOneInput: LinkUpdateOneInput,
-  LinkUpdateDataInput: LinkUpdateDataInput,
-  LinkUpsertNestedInput: LinkUpsertNestedInput,
   TrackUpdateManyWithWhereNestedInput: TrackUpdateManyWithWhereNestedInput,
   TrackScalarWhereInput: TrackScalarWhereInput,
   TrackUpdateManyDataInput: TrackUpdateManyDataInput,
@@ -4985,6 +5029,7 @@ export type ResolversParentTypes = {
   FsFile: FsFile,
   FileOrderByInput: FileOrderByInput,
   FileWhereUniqueInput: FileWhereUniqueInput,
+  VideoWhereUniqueInput: VideoWhereUniqueInput,
   UserWhereUniqueInput: UserWhereUniqueInput,
   TrainingTypeWhereUniqueInput: TrainingTypeWhereUniqueInput,
   TrainingTypeOrderByInput: TrainingTypeOrderByInput,
@@ -5025,6 +5070,20 @@ export type ResolversParentTypes = {
   RatingUpdateManyDataInput: RatingUpdateManyDataInput,
   RatingUpsertWithWhereUniqueWithoutUserInput: RatingUpsertWithWhereUniqueWithoutUserInput,
   UserUpsertNestedInput: UserUpsertNestedInput,
+  VideoCreateInput: VideoCreateInput,
+  FileCreateOneInput: FileCreateOneInput,
+  FileCreateInput: FileCreateInput,
+  UserCreateOneInput: UserCreateOneInput,
+  LinkCreateOneInput: LinkCreateOneInput,
+  LinkCreateInput: LinkCreateInput,
+  LinkWhereUniqueInput: LinkWhereUniqueInput,
+  VideoUpdateInput: VideoUpdateInput,
+  FileUpdateOneInput: FileUpdateOneInput,
+  FileUpdateDataInput: FileUpdateDataInput,
+  FileUpsertNestedInput: FileUpsertNestedInput,
+  LinkUpdateOneInput: LinkUpdateOneInput,
+  LinkUpdateDataInput: LinkUpdateDataInput,
+  LinkUpsertNestedInput: LinkUpsertNestedInput,
   UserUpdateInput: UserUpdateInput,
   TrainingTypeCreateOneInput: TrainingTypeCreateOneInput,
   TrainingTypeCreateInput: TrainingTypeCreateInput,
@@ -5036,16 +5095,8 @@ export type ResolversParentTypes = {
   FormatCreateInput: FormatCreateInput,
   TrackCreateManyInput: TrackCreateManyInput,
   TrackCreateInput: TrackCreateInput,
-  FileCreateOneInput: FileCreateOneInput,
-  FileCreateInput: FileCreateInput,
-  UserCreateOneInput: UserCreateOneInput,
-  LinkCreateOneInput: LinkCreateOneInput,
-  LinkCreateInput: LinkCreateInput,
-  LinkWhereUniqueInput: LinkWhereUniqueInput,
   TrackWhereUniqueInput: TrackWhereUniqueInput,
   VideoCreateManyInput: VideoCreateManyInput,
-  VideoCreateInput: VideoCreateInput,
-  VideoWhereUniqueInput: VideoWhereUniqueInput,
   PictureCreateManyInput: PictureCreateManyInput,
   PictureCreateInput: PictureCreateInput,
   PictureWhereUniqueInput: PictureWhereUniqueInput,
@@ -5099,12 +5150,6 @@ export type ResolversParentTypes = {
   TrackUpdateManyInput: TrackUpdateManyInput,
   TrackUpdateWithWhereUniqueNestedInput: TrackUpdateWithWhereUniqueNestedInput,
   TrackUpdateDataInput: TrackUpdateDataInput,
-  FileUpdateOneInput: FileUpdateOneInput,
-  FileUpdateDataInput: FileUpdateDataInput,
-  FileUpsertNestedInput: FileUpsertNestedInput,
-  LinkUpdateOneInput: LinkUpdateOneInput,
-  LinkUpdateDataInput: LinkUpdateDataInput,
-  LinkUpsertNestedInput: LinkUpsertNestedInput,
   TrackUpdateManyWithWhereNestedInput: TrackUpdateManyWithWhereNestedInput,
   TrackScalarWhereInput: TrackScalarWhereInput,
   TrackUpdateManyDataInput: TrackUpdateManyDataInput,
@@ -5275,6 +5320,9 @@ export type MutationResolvers<ContextType = any, ParentType extends ResolversPar
   uploadFile?: Resolver<ResolversTypes['File'], ParentType, ContextType, RequireFields<MutationUploadFileArgs, 'file'>>,
   updateFile?: Resolver<ResolversTypes['File'], ParentType, ContextType, RequireFields<MutationUpdateFileArgs, 'where' | 'data'>>,
   deleteFile?: Resolver<ResolversTypes['File'], ParentType, ContextType, RequireFields<MutationDeleteFileArgs, 'id'>>,
+  createVideo?: Resolver<ResolversTypes['Video'], ParentType, ContextType, RequireFields<MutationCreateVideoArgs, 'data'>>,
+  updateVideo?: Resolver<ResolversTypes['Video'], ParentType, ContextType, RequireFields<MutationUpdateVideoArgs, 'where' | 'data'>>,
+  deleteVideo?: Resolver<ResolversTypes['Video'], ParentType, ContextType, RequireFields<MutationDeleteVideoArgs, 'id'>>,
   createUser?: Resolver<ResolversTypes['User'], ParentType, ContextType, RequireFields<MutationCreateUserArgs, 'data'>>,
   updateUser?: Resolver<ResolversTypes['User'], ParentType, ContextType, RequireFields<MutationUpdateUserArgs, 'id' | 'data'>>,
   deleteUser?: Resolver<ResolversTypes['User'], ParentType, ContextType, RequireFields<MutationDeleteUserArgs, 'id'>>,
@@ -5326,6 +5374,8 @@ export type QueryResolvers<ContextType = any, ParentType extends ResolversParent
   fsFiles?: Resolver<Array<ResolversTypes['FsFile']>, ParentType, ContextType, RequireFields<QueryFsFilesArgs, 'directory'>>,
   files?: Resolver<Array<ResolversTypes['File']>, ParentType, ContextType, RequireFields<QueryFilesArgs, never>>,
   file?: Resolver<ResolversTypes['File'], ParentType, ContextType, RequireFields<QueryFileArgs, 'where'>>,
+  videos?: Resolver<Array<ResolversTypes['Video']>, ParentType, ContextType, RequireFields<QueryVideosArgs, never>>,
+  video?: Resolver<ResolversTypes['Video'], ParentType, ContextType, RequireFields<QueryVideoArgs, 'where'>>,
   currentUser?: Resolver<ResolversTypes['User'], ParentType, ContextType>,
   user?: Resolver<Maybe<ResolversTypes['User']>, ParentType, ContextType, RequireFields<QueryUserArgs, 'where'>>,
   users?: Resolver<Array<ResolversTypes['User']>, ParentType, ContextType, RequireFields<QueryUsersArgs, never>>,

+ 0 - 41
frontend/src/file/components/VideoSelector.tsx

@@ -1,41 +0,0 @@
-import { useFilesQuery, File } from '../../gql'
-import { FunctionComponent, ChangeEvent, useState } from 'react'
-import Dropdown from '../../dropdown/components/Dropdown'
-import EditFile from './EditFile'
-import { Modal } from '../../modal'
-
-const VideoSelector: FunctionComponent<{
-  name?: string
-  value?: Partial<File>[]
-  onChange: (event: ChangeEvent<HTMLSelectElement>) => void
-}> = ({ name }) => {
-  const [modal, setModal] = useState(false)
-  const { data, error, loading } = useFilesQuery({
-    variables: { where: { mimetype_starts_with: 'video/' } },
-  })
-
-  const videolist = error ? (
-    <option>Error loading videos.</option>
-  ) : loading ? (
-    <option>Loading videos...</option>
-  ) : (
-    data?.files.map((video) => <li>{video.title}</li>)
-  )
-
-  return (
-    <>
-      <Dropdown
-        name='file'
-        value='12'
-        onChange={(event: any) => console.log(event)}
-        items={[]}
-      ></Dropdown>
-      <button onClick={() => setModal(true)}>Add video</button>
-      <Modal state={[modal, setModal]}>
-        <EditFile file={data?.files[0] || {}} />
-      </Modal>
-    </>
-  )
-}
-
-export default VideoSelector

+ 26 - 0
frontend/src/file/file.graphql

@@ -106,3 +106,29 @@ mutation deleteFile($id: ID!) {
     id
   }
 }
+
+query videos {
+  videos {
+    id
+    order
+    file {
+      id
+      path
+      comment
+      mimetype
+      thumbnail
+      filename
+      duration
+      title
+      artist
+    }
+    link {
+      id
+      url
+      comment
+      duration
+      title
+      artist
+    }
+  }
+}

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

@@ -2264,6 +2264,9 @@ export type Mutation = {
   uploadFile: File,
   updateFile: File,
   deleteFile: File,
+  createVideo: Video,
+  updateVideo: Video,
+  deleteVideo: Video,
   createUser: User,
   updateUser: User,
   deleteUser: User,
@@ -2306,6 +2309,22 @@ export type MutationDeleteFileArgs = {
 };
 
 
+export type MutationCreateVideoArgs = {
+  data: VideoCreateInput
+};
+
+
+export type MutationUpdateVideoArgs = {
+  where: VideoWhereUniqueInput,
+  data: VideoUpdateInput
+};
+
+
+export type MutationDeleteVideoArgs = {
+  id: Scalars['ID']
+};
+
+
 export type MutationCreateUserArgs = {
   data: UserCreateInput
 };
@@ -2671,6 +2690,8 @@ export type Query = {
   fsFiles: Array<FsFile>,
   files: Array<File>,
   file: File,
+  videos: Array<Video>,
+  video: Video,
   currentUser: User,
   user?: Maybe<User>,
   users: Array<User>,
@@ -2741,6 +2762,22 @@ export type QueryFileArgs = {
 };
 
 
+export type QueryVideosArgs = {
+  where?: Maybe<VideoWhereInput>,
+  orderBy?: Maybe<VideoOrderByInput>,
+  skip?: Maybe<Scalars['Int']>,
+  after?: Maybe<Scalars['String']>,
+  before?: Maybe<Scalars['String']>,
+  first?: Maybe<Scalars['Int']>,
+  last?: Maybe<Scalars['Int']>
+};
+
+
+export type QueryVideoArgs = {
+  where: VideoWhereUniqueInput
+};
+
+
 export type QueryUserArgs = {
   where: UserWhereUniqueInput
 };
@@ -4485,6 +4522,12 @@ export type VideoUpdateDataInput = {
   link?: Maybe<LinkUpdateOneInput>,
 };
 
+export type VideoUpdateInput = {
+  order?: Maybe<Scalars['Int']>,
+  file?: Maybe<FileUpdateOneInput>,
+  link?: Maybe<LinkUpdateOneInput>,
+};
+
 export type VideoUpdateManyDataInput = {
   order?: Maybe<Scalars['Int']>,
 };
@@ -4665,6 +4708,14 @@ export type DeleteFileMutationVariables = {
 
 export type DeleteFileMutation = { deleteFile: Pick<File, 'id'> };
 
+export type VideosQueryVariables = {};
+
+
+export type VideosQuery = { videos: Array<(
+    Pick<Video, 'id' | 'order'>
+    & { file: Maybe<Pick<File, 'id' | 'path' | 'comment' | 'mimetype' | 'thumbnail' | 'filename' | 'duration' | 'title' | 'artist'>>, link: Maybe<Pick<Link, 'id' | 'url' | 'comment' | 'duration' | 'title' | 'artist'>> }
+  )> };
+
 export type TrainingArchiveQueryVariables = {
   skip?: Maybe<Scalars['Int']>,
   first?: Maybe<Scalars['Int']>
@@ -5355,6 +5406,58 @@ export function useDeleteFileMutation(baseOptions?: ApolloReactHooks.MutationHoo
 export type DeleteFileMutationHookResult = ReturnType<typeof useDeleteFileMutation>;
 export type DeleteFileMutationResult = ApolloReactCommon.MutationResult<DeleteFileMutation>;
 export type DeleteFileMutationOptions = ApolloReactCommon.BaseMutationOptions<DeleteFileMutation, DeleteFileMutationVariables>;
+export const VideosDocument = gql`
+    query videos {
+  videos {
+    id
+    order
+    file {
+      id
+      path
+      comment
+      mimetype
+      thumbnail
+      filename
+      duration
+      title
+      artist
+    }
+    link {
+      id
+      url
+      comment
+      duration
+      title
+      artist
+    }
+  }
+}
+    `;
+
+/**
+ * __useVideosQuery__
+ *
+ * To run a query within a React component, call `useVideosQuery` and pass it any options that fit your needs.
+ * When your component renders, `useVideosQuery` 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 } = useVideosQuery({
+ *   variables: {
+ *   },
+ * });
+ */
+export function useVideosQuery(baseOptions?: ApolloReactHooks.QueryHookOptions<VideosQuery, VideosQueryVariables>) {
+        return ApolloReactHooks.useQuery<VideosQuery, VideosQueryVariables>(VideosDocument, baseOptions);
+      }
+export function useVideosLazyQuery(baseOptions?: ApolloReactHooks.LazyQueryHookOptions<VideosQuery, VideosQueryVariables>) {
+          return ApolloReactHooks.useLazyQuery<VideosQuery, VideosQueryVariables>(VideosDocument, baseOptions);
+        }
+export type VideosQueryHookResult = ReturnType<typeof useVideosQuery>;
+export type VideosLazyQueryHookResult = ReturnType<typeof useVideosLazyQuery>;
+export type VideosQueryResult = ApolloReactCommon.QueryResult<VideosQuery, VideosQueryVariables>;
 export const TrainingArchiveDocument = gql`
     query trainingArchive($skip: Int, $first: Int) {
   trainingArchive(skip: $skip, first: $first) {

+ 9 - 45
frontend/src/training/components/BlockInputs.tsx

@@ -5,7 +5,8 @@ import ExerciseInstanceInputs from './ExerciseInstanceInputs'
 import { TBlock } from '../types'
 import BlockSelector from './BlockSelector'
 import BlockList from './BlockList'
-import VideoSelector from '../../file/components/VideoSelector'
+import ExerciseList from './ExersiceList'
+import VideoList from './VideoList'
 
 interface IBlockInputs {
   onChange: GenericEventHandler
@@ -47,7 +48,7 @@ const BlockInputs = (props?: IBlockInputs) => {
         type='number'
         onChange={onChange}
       />
-      <VideoSelector name={`${name}.videos`} value={value.videos} onChange={onChange} />
+      <VideoList name={`${name}.videos`} value={value.videos} onChange={onChange} />
       <label>Blocks</label>
       <BlockList
         name={`${name}.blocks`}
@@ -55,50 +56,13 @@ const BlockInputs = (props?: IBlockInputs) => {
         onChange={onChange}
         className='training-blocks'
       />
-      <button
-        onClick={(event) => {
-          event.preventDefault()
-          const newBlock = emptyBlockInstance({
-            order: value.blocks ? value.blocks.length : 0,
-          })
-          onChange({
-            target: {
-              type: 'custom',
-              name: `${name}.blocks`,
-              value: value.blocks ? [...value.blocks, newBlock] : [newBlock],
-            },
-          })
-        }}
-        type='button'
-      >
-        Add block
-      </button>
       <label>Exercises</label>
-      {value.exercises && value.exercises.length > 0 && (
-        <ExerciseInstanceInputs
-          name={`${name}.exercises`}
-          value={value.exercises}
-          onChange={onChange}
-        />
-      )}
-      <button
-        onClick={(event) => {
-          event.preventDefault()
-          const newExercise = emptyExerciseInstance({
-            order: value.exercises ? value.exercises.length : 0,
-          })
-          onChange({
-            target: {
-              type: 'custom',
-              name: `${name}.exercises`,
-              value: value.exercises ? [...value.exercises, newExercise] : [newExercise],
-            },
-          })
-        }}
-        type='button'
-      >
-        Add exercise
-      </button>
+      <ExerciseList
+        name={`${name}.exercises`}
+        value={value.exercises}
+        onChange={onChange}
+        className={'training-exercises'}
+      />
 
       <style jsx>{`
         .${className} {

+ 1 - 1
frontend/src/training/components/BlockInstanceInputs.tsx

@@ -2,7 +2,7 @@ import BlockInputs from './BlockInputs'
 import { TextInput } from '../../form'
 import { TBlockInstance, TBlock } from '../types'
 import theme from '../../styles/theme'
-import { useState, useEffect, ChangeEvent } from 'react'
+import { useState, ChangeEvent } from 'react'
 import { emptyBlock, countBlocksAndExercises } from '../utils'
 import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
 import { faChevronRight, faChevronDown } from '@fortawesome/free-solid-svg-icons'

+ 5 - 14
frontend/src/training/components/ExerciseInputs.tsx

@@ -4,26 +4,17 @@ import ExerciseSelector from './ExerciseSelector'
 
 interface IExerciseInputs {
   onChange: GenericEventHandler
-  value: TExercise
+  value?: TExercise
   name: string
 }
 
 const ExerciseInputs = ({ onChange, value, name }: IExerciseInputs) => {
+  if (!value) return null
   return (
     <>
-      <p>ex: {value.id}</p>
-      <ExerciseSelector
-        name={name}
-        value={value}
-        label='Existing exercise'
-        onChange={onChange}
-      />
-      <TextInput
-        name={`${name}.name`}
-        label='Name'
-        value={value.name}
-        onChange={onChange}
-      />
+      <p>Exercise: {value?.id}</p>
+      <ExerciseSelector name={name} value={value} label='Existing exercise' onChange={onChange} />
+      <TextInput name={`${name}.name`} label='Name' value={value.name} onChange={onChange} />
       <TextInput
         name={`${name}.description`}
         label='Description'

+ 134 - 100
frontend/src/training/components/ExerciseInstanceInputs.tsx

@@ -1,119 +1,153 @@
-import { useState, useEffect } from 'react'
-import arrayMove from 'array-move'
 import ExerciseInputs from './ExerciseInputs'
-import { SortableList } from '../../sortable'
 import { TextInput } from '../../form'
-import { TExerciseInstance } from '../types'
+import { TExerciseInstance, TExercise } from '../types'
+import theme from '../../styles/theme'
+import { useState, ChangeEvent } from 'react'
+import { emptyExercise } from '../utils'
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
+import { faChevronRight, faChevronDown } from '@fortawesome/free-solid-svg-icons'
+import ExerciseSelector from './ExerciseSelector'
 
 const ExerciseInstanceInputs = ({
-  value = [],
+  value,
   name,
-  onChange
+  onChange,
+  className = 'ei-fields',
 }: {
-  value?: TExerciseInstance[]
+  value: TExerciseInstance
   name: string
   onChange: GenericEventHandler
+  className?: string
 }) => {
-  const [state, setState] = useState(value.map(item => item.id))
+  const [show, setShow] = useState(true)
+  const [source, setSource] = useState<'new' | 'existing'>('new')
+  const [newItem, setNewItem] = useState(emptyExercise())
+  const [existingItem, setExistingItem] = useState<undefined | TExercise>()
 
-  function updateOrderProperty<T extends { id: U }, U>(
-    values: T[],
-    orderList: U[]
-  ) {
-    const orderedValues = values.map(value => {
-      const order = orderList.findIndex(orderedId => orderedId === value.id)
-      return { ...value, order }
-    })
-    onChange({ target: { type: 'custom', name, value: orderedValues } })
+  function handleSourceChange(event: ChangeEvent<HTMLInputElement>) {
+    const { target } = event
+    if (target.value === 'new') {
+      if (!value.exercise?.id.startsWith('++')) setExistingItem(value.exercise)
+      setSource('new')
+      onChange({ target: { type: 'custom', name: `${name}.exercise`, value: newItem } })
+    } else if (target.value === 'existing') {
+      if (value.exercise?.id.startsWith('++')) setNewItem(value.exercise)
+      setSource('existing')
+      onChange({ target: { type: 'custom', name: `${name}.exercise`, value: existingItem } })
+    }
   }
 
-  function onSortEnd({
-    oldIndex,
-    newIndex
-  }: {
-    oldIndex: number
-    newIndex: number
-  }) {
-    const newOrder = arrayMove(state, oldIndex, newIndex)
-    setState(newOrder)
-    updateOrderProperty(value, newOrder)
-  }
-
-  useEffect(() => {
-    const missingIds = value
-      .filter(item => !state.includes(item.id))
-      .filter(item => !item.id.startsWith('--'))
-      .map(item => item.id)
-    const stateWithoutRemovedItems = state.filter(stateId =>
-      value.find(item => stateId === item.id)
-    )
-    setState([...stateWithoutRemovedItems, ...missingIds])
-  }, [value])
+  return (
+    <fieldset className={className}>
+      {!value.id.startsWith('++') && (
+        <div className='ei-info'>
+          <div>Exercise Instance ID{value.id}</div>
+          <div>Created at:</div>
+        </div>
+      )}
+      <TextInput
+        name={`${name}.order`}
+        label='Order'
+        value={value.order}
+        type='number'
+        onChange={onChange}
+        className='ei-order'
+      />
+      <TextInput
+        name={`${name}.rounds`}
+        label='Repetitions'
+        value={value.repetitions}
+        type='number'
+        onChange={onChange}
+        className='ei-rounds'
+      />
+      <TextInput
+        name={`${name}.variation`}
+        label='Variation'
+        value={value.variation}
+        onChange={onChange}
+        className='ei-variation'
+      />
+      <div className='ei-exercise'>
+        <FontAwesomeIcon
+          icon={show ? faChevronDown : faChevronRight}
+          height={16}
+          onClick={() => setShow(!show)}
+        />
+        <input
+          type='radio'
+          name='source'
+          id='source-new'
+          value='new'
+          checked={source === 'new'}
+          onChange={handleSourceChange}
+        />
+        <label htmlFor='source-new'>New exercise</label>
+        <input
+          type='radio'
+          name='source'
+          id='source-existing'
+          value='existing'
+          checked={source === 'existing'}
+          onChange={handleSourceChange}
+        />
+        <label htmlFor='source-existing'>Existing exercise</label>
+        {value.exercise ? (
+          <div>Exercise: {value.exercise.name ?? value.exercise.description?.substr(0, 50)}</div>
+        ) : (
+          <div>No exercise selected</div>
+        )}
 
-  const items = state
-    .map(stateId => {
-      const itemIndex = value.findIndex(item => item.id === stateId)
-      if (itemIndex < 0) return null
-      const item = value[itemIndex]
-      return (
-        <div key={item.id}>
-          <p>{item.id}</p>
-          <TextInput
-            name={`${name}.${itemIndex}.order`}
-            label='Order'
-            value={item.order}
-            type='number'
-            onChange={onChange}
-          />
-          <TextInput
-            name={`${name}.${itemIndex}.repetitions`}
-            label='Repetitions'
-            value={item.repetitions}
-            type='number'
-            onChange={onChange}
-          />
-          <TextInput
-            name={`${name}.${itemIndex}.variation`}
-            label='Variation'
-            value={item.variation}
-            onChange={onChange}
-          />
-          {item.exercise && (
-            <ExerciseInputs
-              name={`${name}.${itemIndex}.exercise`}
-              value={item.exercise}
+        {show &&
+          (source === 'new' ? (
+            <ExerciseInputs name={`${name}.exercise`} value={value.exercise} onChange={onChange} />
+          ) : source === 'existing' ? (
+            <ExerciseSelector
+              name={`${name}.exercise`}
+              value={value.exercise}
+              label='Existing exercise'
               onChange={onChange}
             />
-          )}
-          <button
-            type='button'
-            onClick={ev => {
-              const newOrder = state.filter(orderedId => item.id !== orderedId)
-              setState(newOrder)
-              const newValues = item.id?.startsWith('++')
-                ? [...value.slice(0, itemIndex), ...value.slice(itemIndex + 1)]
-                : [
-                    ...value.slice(0, itemIndex),
-                    { ...item, id: `--${item.id}` },
-                    ...value.slice(itemIndex + 1)
-                  ]
-              updateOrderProperty(newValues, newOrder)
-            }}
-          >
-            Delete Exercise
-          </button>
-        </div>
-      )
-    })
-    .filter(block => block !== null)
+          ) : (
+            <p>Please select a exercise</p>
+          ))}
+      </div>
 
-  return (
-    <SortableList
-      items={items}
-      onSortEnd={onSortEnd}
-      useDragHandle
-      lockAxis={'y'}
-    />
+      <style jsx>{`
+        .ei-exercise > label,
+        .ei-exercise > input[type='radio'] {
+          display: inline;
+          width: auto;
+        }
+        @media (min-width: ${theme.midsize}) {
+          .${className} {
+            display: grid;
+            grid-template-areas:
+              'info info info info'
+              'order  rounds variation variation'
+              'exercise  exercise  exercise exercise'
+              'button button button button';
+            grid-template-columns: repeat(4, 1fr);
+          }
+
+          .${className} :global(.ei-order) {
+            grid-area: order;
+          }
+          .${className} :global(.ei-rounds) {
+            grid-area: rounds;
+          }
+          .${className} :global(.ei-variation) {
+            grid-area: variation;
+          }
+          .${className} :global(.ei-exercise) {
+            grid-area: exercise;
+          }
+          .${className} :global(.ei-button) {
+            grid-area: button;
+          }
+        }
+      `}</style>
+    </fieldset>
   )
 }
 

+ 101 - 0
frontend/src/training/components/ExersiceList.tsx

@@ -0,0 +1,101 @@
+import { useState, useEffect } from 'react'
+import arrayMove from 'array-move'
+import { SortableList } from '../../sortable'
+import { TExerciseInstance } from '../types'
+import { emptyExerciseInstance } from '../utils'
+import ExerciseInstanceInputs from './ExerciseInstanceInputs'
+import { customEvent } from '../../lib/customEvent'
+
+const ExerciseList = ({
+  value = [],
+  name,
+  onChange,
+  className,
+}: {
+  value?: TExerciseInstance[]
+  name: string
+  onChange: GenericEventHandler
+  className?: string
+}) => {
+  const [state, setState] = useState(value.map((item) => item.id))
+
+  function updateOrderProperty<T extends { id: U }, U>(values: T[], orderList: U[]) {
+    const orderedValues = values.map((value) => {
+      const order = orderList.findIndex((orderedId) => orderedId === value.id)
+      return { ...value, order }
+    })
+    onChange({ target: { type: 'custom', name, value: orderedValues } })
+  }
+
+  function onSortEnd({ oldIndex, newIndex }: { oldIndex: number; newIndex: number }) {
+    const newOrder = arrayMove(state, oldIndex, newIndex)
+    setState(newOrder)
+    updateOrderProperty(value, newOrder)
+  }
+
+  useEffect(() => {
+    const missingIds = value
+      .filter((item) => !state.includes(item.id))
+      .filter((item) => !item.id.startsWith('--'))
+      .map((item) => item.id)
+    const stateWithoutRemovedItems = state.filter((stateId) =>
+      value.find((item) => stateId === item.id)
+    )
+    setState([...stateWithoutRemovedItems, ...missingIds])
+  }, [value])
+
+  const items = state
+    .map((stateId) => {
+      const itemIndex = value.findIndex((item) => item.id === stateId)
+      if (itemIndex < 0) return null
+      const item = value[itemIndex]
+      return (
+        <ExerciseInstanceInputs
+          key={item.id}
+          name={`${name}.${itemIndex}`}
+          value={item}
+          onChange={onChange}
+        />
+      )
+    })
+    .filter((exercise) => exercise !== null)
+
+  return (
+    <fieldset className={className}>
+      <h2>Exercises</h2>
+      <SortableList items={items} onSortEnd={onSortEnd} useDragHandle lockAxis={'y'} />
+
+      <button
+        onClick={(event) => {
+          event.preventDefault()
+          onChange(addExercise(name, value))
+        }}
+        type='button'
+      >
+        Add exercise
+      </button>
+      <style jsx>
+        {`
+          h2 {
+            margin: 0.4em 0.3em;
+            padding: 0;
+          }
+          fieldset {
+            padding: 0;
+          }
+        `}
+      </style>
+    </fieldset>
+  )
+}
+
+export default ExerciseList
+
+export function addExercise(name: string, exerciseList: TExerciseInstance[]) {
+  const newExercise = emptyExerciseInstance({
+    order: exerciseList
+      ? exerciseList.filter((exercise) => !exercise.id.startsWith('--')).length
+      : 0,
+  })
+  return customEvent(name, [...exerciseList, newExercise])
+}

+ 68 - 0
frontend/src/training/components/FileSelector.tsx

@@ -0,0 +1,68 @@
+import { useExercisesQuery } from '../../gql'
+import { useState, useEffect } from 'react'
+import { TExercise } from '../types'
+
+interface IExerciseSelector {
+  value?: TExercise
+  onChange: GenericEventHandler
+  name?: string
+  label?: string
+}
+
+const ExerciseSelector = ({
+  value,
+  onChange,
+  name = 'exercise',
+  label = 'Exercise'
+}: IExerciseSelector) => {
+  const [state, setState] = useState(value?.id ?? '')
+  const exercises = useExercisesQuery()
+
+  useEffect(() => {
+    setState(value?.id || '')
+  }, [value])
+
+  return (
+    <>
+      <label>{label}</label>
+      <select
+        id={name}
+        name={name}
+        value={state}
+        onChange={ev => setState(ev.target.value)}
+      >
+        {exercises.loading && 'loading exercises...'}
+        {exercises.error && 'error loading exercises'}
+        {exercises.data &&
+          exercises.data.exercises.map(exercise => (
+            <option key={exercise.id} value={exercise.id}>
+              {[exercise.name, exercise.description?.slice(0, 60)]
+                .filter(exercise => !!exercise)
+                .join(' - ')}
+            </option>
+          ))}
+      </select>
+      <button
+        type='button'
+        onClick={event => {
+          const exercise =
+            exercises.data &&
+            exercises.data.exercises.find(exercise => exercise.id === state)
+          if (!exercise) return
+          const changeEvent: CustomChangeEvent = {
+            target: {
+              type: 'custom',
+              value: exercise,
+              name
+            }
+          }
+          onChange(changeEvent)
+        }}
+      >
+        Use
+      </button>
+    </>
+  )
+}
+
+export default ExerciseSelector

+ 85 - 0
frontend/src/training/components/VideoInputs.tsx

@@ -0,0 +1,85 @@
+import { TextInput } from '../../form'
+import theme from '../../styles/theme'
+import { useState, ChangeEvent } from 'react'
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
+import { faChevronRight, faChevronDown } from '@fortawesome/free-solid-svg-icons'
+import { Video } from '../../gql'
+import FileSelector from '../../file/components/FileSelector'
+
+const VideoInstanceInputs = ({
+  value,
+  name,
+  onChange,
+  className = 'ei-fields',
+}: {
+  value: Partial<Video>
+  name: string
+  onChange: GenericEventHandler
+  className?: string
+}) => {
+  const [show, setShow] = useState(true)
+
+  return (
+    <fieldset className={className}>
+      {!value.id?.startsWith('++') && (
+        <div className='ei-info'>
+          <div>Video Instance ID{value.id}</div>
+          <div>Created at:</div>
+        </div>
+      )}
+      <TextInput
+        name={`${name}.order`}
+        label='Order'
+        value={value.order}
+        type='number'
+        onChange={onChange}
+        className='vi-order'
+      />
+      <div className='ei-video'>
+        <FontAwesomeIcon
+          icon={show ? faChevronDown : faChevronRight}
+          height={16}
+          onClick={() => setShow(!show)}
+        />
+        <FileSelector name={`${name}.file`} value={value.file} onChange={onChange} />
+      </div>
+
+      <style jsx>{`
+        .ei-video > label,
+        .ei-video > input[type='radio'] {
+          display: inline;
+          width: auto;
+        }
+        @media (min-width: ${theme.midsize}) {
+          .${className} {
+            display: grid;
+            grid-template-areas:
+              'info info info info'
+              'order  rounds variation variation'
+              'video  video  video video'
+              'button button button button';
+            grid-template-columns: repeat(4, 1fr);
+          }
+
+          .${className} :global(.ei-order) {
+            grid-area: order;
+          }
+          .${className} :global(.ei-rounds) {
+            grid-area: rounds;
+          }
+          .${className} :global(.ei-variation) {
+            grid-area: variation;
+          }
+          .${className} :global(.ei-video) {
+            grid-area: video;
+          }
+          .${className} :global(.ei-button) {
+            grid-area: button;
+          }
+        }
+      `}</style>
+    </fieldset>
+  )
+}
+
+export default VideoInstanceInputs

+ 96 - 0
frontend/src/training/components/VideoList.tsx

@@ -0,0 +1,96 @@
+import { useState, useEffect } from 'react'
+import arrayMove from 'array-move'
+import { SortableList } from '../../sortable'
+import { customEvent } from '../../lib/customEvent'
+import { Video } from '../../gql'
+import VideoInputs from './VideoInputs'
+
+const VideoList = ({
+  value = [],
+  name,
+  onChange,
+  className,
+}: {
+  value?: (Partial<Video> & { id: string })[]
+  name: string
+  onChange: GenericEventHandler
+  className?: string
+}) => {
+  const [state, setState] = useState(value.map((item) => item.id))
+
+  function updateOrderProperty<T extends { id: string }, U extends T['id']>(
+    values: T[],
+    orderList: U[]
+  ) {
+    const orderedValues = values.map((value) => {
+      const order = orderList.findIndex((orderedId) => orderedId === value.id)
+      return { ...value, order }
+    })
+    onChange({ target: { type: 'custom', name, value: orderedValues } })
+  }
+
+  function onSortEnd({ oldIndex, newIndex }: { oldIndex: number; newIndex: number }) {
+    const newOrder = arrayMove(state, oldIndex, newIndex)
+    setState(newOrder)
+    updateOrderProperty(value, newOrder)
+  }
+
+  useEffect(() => {
+    const missingIds = value
+      .filter((item) => !state.includes(item.id))
+      .filter((item) => !item.id?.startsWith('--'))
+      .map((item) => item.id)
+    const stateWithoutRemovedItems = state.filter((stateId) =>
+      value.find((item) => stateId === item.id)
+    )
+    setState([...stateWithoutRemovedItems, ...missingIds])
+  }, [value])
+
+  const items = state
+    .map((stateId) => {
+      const itemIndex = value.findIndex((item) => item.id === stateId)
+      if (itemIndex < 0) return null
+      const item = value[itemIndex]
+      return (
+        <VideoInputs key={item.id} name={`${name}.${itemIndex}`} value={item} onChange={onChange} />
+      )
+    })
+    .filter((video) => video !== null)
+
+  return (
+    <fieldset className={className}>
+      <h2>Videos</h2>
+      <SortableList items={items} onSortEnd={onSortEnd} useDragHandle lockAxis={'y'} />
+
+      <button
+        onClick={(event) => {
+          event.preventDefault()
+          onChange(addVideo(name, value))
+        }}
+        type='button'
+      >
+        Add video
+      </button>
+      <style jsx>
+        {`
+          h2 {
+            margin: 0.4em 0.3em;
+            padding: 0;
+          }
+          fieldset {
+            padding: 0;
+          }
+        `}
+      </style>
+    </fieldset>
+  )
+}
+
+export default VideoList
+
+export function addVideo(name: string, videoList: Partial<Video>[]) {
+  const newVideo = {
+    order: videoList ? videoList.filter((video) => !video.id?.startsWith('--')).length : 0,
+  }
+  return customEvent(name, [...videoList, newVideo])
+}