ソースを参照

working on file uploads. fixed nginx settings. removed unecessary npm installs in dev setup.

Tomi Cvetic 4 年 前
コミット
cbaa2c6b14
57 ファイル変更4027 行追加1934 行削除
  1. 4 1
      backend/Dockerfile
  2. 538 211
      backend/database/generated/prisma-client/index.ts
  3. 259 68
      backend/database/generated/prisma-client/prisma-schema.ts
  4. 512 89
      backend/database/generated/prisma.graphql
  5. 22 0
      backend/package-lock.json
  6. 4 0
      backend/package.json
  7. 20 3
      backend/schema.graphql
  8. 0 1
      backend/src/file/constants.ts
  9. 48 8
      backend/src/file/resolvers.ts
  10. 770 58
      backend/src/gql/resolvers.ts
  11. 15 4
      backend/src/user/resolvers.ts
  12. 3 3
      frontend/Dockerfile
  13. 3 4
      frontend/jest.config.js
  14. 216 554
      frontend/package-lock.json
  15. 8 8
      frontend/package.json
  16. 30 3
      frontend/pages/admin/block/index.tsx
  17. 30 3
      frontend/pages/admin/exercise/index.tsx
  18. 0 0
      frontend/pages/admin/file/create.tsx
  19. 1 20
      frontend/pages/admin/training/[id].tsx
  20. 17 9
      frontend/pages/admin/training/create.tsx
  21. 5 2
      frontend/pages/admin/training/index.tsx
  22. 97 0
      frontend/pages/admin/user/index.tsx
  23. 0 13
      frontend/pages/admin/users.tsx
  24. 3 0
      frontend/pages/admin/video/[id].tsx
  25. 23 0
      frontend/pages/admin/video/create.tsx
  26. 55 0
      frontend/pages/admin/video/index.tsx
  27. 5 0
      frontend/src/app/components/AdminSideBar.tsx
  28. 1 1
      frontend/src/app/components/Header.tsx
  29. 35 44
      frontend/src/dropdown/components/Dropdown.tsx
  30. 61 0
      frontend/src/file/components/EditVideo.tsx
  31. 58 0
      frontend/src/file/components/FileManager.tsx
  32. 63 0
      frontend/src/file/components/FileSelector.tsx
  33. 24 90
      frontend/src/file/components/UploadFile.tsx
  34. 34 0
      frontend/src/file/components/VideoSelector.tsx
  35. 120 3
      frontend/src/file/file.graphql
  36. 18 3
      frontend/src/file/utils.ts
  37. 536 445
      frontend/src/gql/index.tsx
  38. 3 0
      frontend/src/lib/customEvent.ts
  39. 1 0
      frontend/src/modal/styles/index.ts
  40. 0 8
      frontend/src/styles/global.ts
  41. 81 39
      frontend/src/training/__tests__/utils.test.ts
  42. 11 28
      frontend/src/training/components/BlockInputs.tsx
  43. 92 20
      frontend/src/training/components/BlockInstanceInputs.tsx
  44. 25 43
      frontend/src/training/components/BlockSelector.tsx
  45. 4 13
      frontend/src/training/components/EditBlock.tsx
  46. 31 26
      frontend/src/training/components/EditTraining.tsx
  47. 23 12
      frontend/src/training/components/Ratings.tsx
  48. 26 16
      frontend/src/training/components/Registrations.tsx
  49. 3 1
      frontend/src/training/components/TrainingBlock.tsx
  50. 16 4
      frontend/src/training/training.graphql
  51. 2 4
      frontend/src/training/types.ts
  52. 44 60
      frontend/src/training/utils.ts
  53. 9 5
      frontend/src/user/components/DeleteUserButton.tsx
  54. 3 0
      frontend/src/user/types.ts
  55. 10 4
      frontend/src/user/user.graphql
  56. 2 1
      frontend/tsconfig.jest.json
  57. 3 2
      proxy/nginx.conf

+ 4 - 1
backend/Dockerfile

@@ -6,6 +6,9 @@ ENV PATH /app/node_modules/.bin:$PATH
 
 COPY package.json /app/package.json
 
-RUN npm install --silent
+RUN apk add --no-cache ffmpeg 
+
+# Do we really need this?
+#RUN npm install --silent
 
 CMD ["npm", "run", "start"]

ファイルの差分が大きいため隠しています
+ 538 - 211
backend/database/generated/prisma-client/index.ts


+ 259 - 68
backend/database/generated/prisma-client/prisma-schema.ts

@@ -70,8 +70,8 @@ type Block {
   format: Format!
   rest: Int
   tracks(where: TrackWhereInput, orderBy: TrackOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): [Track!]
-  videos: [String!]!
-  pictures: [String!]!
+  videos(where: VideoWhereInput, orderBy: VideoOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): [Video!]
+  pictures(where: PictureWhereInput, orderBy: PictureOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): [Picture!]
   blocks(where: BlockInstanceWhereInput, orderBy: BlockInstanceOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): [BlockInstance!]
   parentBlockInstances(where: BlockInstanceWhereInput, orderBy: BlockInstanceOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): [BlockInstance!]
   exercises(where: ExerciseInstanceWhereInput, orderBy: ExerciseInstanceOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): [ExerciseInstance!]
@@ -91,8 +91,8 @@ input BlockCreateInput {
   format: FormatCreateOneInput!
   rest: Int
   tracks: TrackCreateManyInput
-  videos: BlockCreatevideosInput
-  pictures: BlockCreatepicturesInput
+  videos: VideoCreateManyInput
+  pictures: PictureCreateManyInput
   blocks: BlockInstanceCreateManyWithoutParentBlockInput
   parentBlockInstances: BlockInstanceCreateManyWithoutBlockInput
   exercises: ExerciseInstanceCreateManyWithoutParentBlockInstancesInput
@@ -113,14 +113,6 @@ input BlockCreateOneWithoutParentBlockInstancesInput {
   connect: BlockWhereUniqueInput
 }
 
-input BlockCreatepicturesInput {
-  set: [String!]
-}
-
-input BlockCreatevideosInput {
-  set: [String!]
-}
-
 input BlockCreateWithoutBlocksInput {
   id: ID
   title: String!
@@ -129,8 +121,8 @@ input BlockCreateWithoutBlocksInput {
   format: FormatCreateOneInput!
   rest: Int
   tracks: TrackCreateManyInput
-  videos: BlockCreatevideosInput
-  pictures: BlockCreatepicturesInput
+  videos: VideoCreateManyInput
+  pictures: PictureCreateManyInput
   parentBlockInstances: BlockInstanceCreateManyWithoutBlockInput
   exercises: ExerciseInstanceCreateManyWithoutParentBlockInstancesInput
 }
@@ -143,8 +135,8 @@ input BlockCreateWithoutExercisesInput {
   format: FormatCreateOneInput!
   rest: Int
   tracks: TrackCreateManyInput
-  videos: BlockCreatevideosInput
-  pictures: BlockCreatepicturesInput
+  videos: VideoCreateManyInput
+  pictures: PictureCreateManyInput
   blocks: BlockInstanceCreateManyWithoutParentBlockInput
   parentBlockInstances: BlockInstanceCreateManyWithoutBlockInput
 }
@@ -157,8 +149,8 @@ input BlockCreateWithoutParentBlockInstancesInput {
   format: FormatCreateOneInput!
   rest: Int
   tracks: TrackCreateManyInput
-  videos: BlockCreatevideosInput
-  pictures: BlockCreatepicturesInput
+  videos: VideoCreateManyInput
+  pictures: PictureCreateManyInput
   blocks: BlockInstanceCreateManyWithoutParentBlockInput
   exercises: ExerciseInstanceCreateManyWithoutParentBlockInstancesInput
 }
@@ -522,8 +514,6 @@ type BlockPreviousValues {
   description: String
   duration: Int
   rest: Int
-  videos: [String!]!
-  pictures: [String!]!
 }
 
 type BlockSubscriptionPayload {
@@ -551,8 +541,8 @@ input BlockUpdateInput {
   format: FormatUpdateOneRequiredInput
   rest: Int
   tracks: TrackUpdateManyInput
-  videos: BlockUpdatevideosInput
-  pictures: BlockUpdatepicturesInput
+  videos: VideoUpdateManyInput
+  pictures: PictureUpdateManyInput
   blocks: BlockInstanceUpdateManyWithoutParentBlockInput
   parentBlockInstances: BlockInstanceUpdateManyWithoutBlockInput
   exercises: ExerciseInstanceUpdateManyWithoutParentBlockInstancesInput
@@ -563,8 +553,6 @@ input BlockUpdateManyMutationInput {
   description: String
   duration: Int
   rest: Int
-  videos: BlockUpdatevideosInput
-  pictures: BlockUpdatepicturesInput
 }
 
 input BlockUpdateOneRequiredWithoutExercisesInput {
@@ -590,14 +578,6 @@ input BlockUpdateOneWithoutBlocksInput {
   connect: BlockWhereUniqueInput
 }
 
-input BlockUpdatepicturesInput {
-  set: [String!]
-}
-
-input BlockUpdatevideosInput {
-  set: [String!]
-}
-
 input BlockUpdateWithoutBlocksDataInput {
   title: String
   description: String
@@ -605,8 +585,8 @@ input BlockUpdateWithoutBlocksDataInput {
   format: FormatUpdateOneRequiredInput
   rest: Int
   tracks: TrackUpdateManyInput
-  videos: BlockUpdatevideosInput
-  pictures: BlockUpdatepicturesInput
+  videos: VideoUpdateManyInput
+  pictures: PictureUpdateManyInput
   parentBlockInstances: BlockInstanceUpdateManyWithoutBlockInput
   exercises: ExerciseInstanceUpdateManyWithoutParentBlockInstancesInput
 }
@@ -618,8 +598,8 @@ input BlockUpdateWithoutExercisesDataInput {
   format: FormatUpdateOneRequiredInput
   rest: Int
   tracks: TrackUpdateManyInput
-  videos: BlockUpdatevideosInput
-  pictures: BlockUpdatepicturesInput
+  videos: VideoUpdateManyInput
+  pictures: PictureUpdateManyInput
   blocks: BlockInstanceUpdateManyWithoutParentBlockInput
   parentBlockInstances: BlockInstanceUpdateManyWithoutBlockInput
 }
@@ -631,8 +611,8 @@ input BlockUpdateWithoutParentBlockInstancesDataInput {
   format: FormatUpdateOneRequiredInput
   rest: Int
   tracks: TrackUpdateManyInput
-  videos: BlockUpdatevideosInput
-  pictures: BlockUpdatepicturesInput
+  videos: VideoUpdateManyInput
+  pictures: PictureUpdateManyInput
   blocks: BlockInstanceUpdateManyWithoutParentBlockInput
   exercises: ExerciseInstanceUpdateManyWithoutParentBlockInstancesInput
 }
@@ -715,6 +695,12 @@ input BlockWhereInput {
   tracks_every: TrackWhereInput
   tracks_some: TrackWhereInput
   tracks_none: TrackWhereInput
+  videos_every: VideoWhereInput
+  videos_some: VideoWhereInput
+  videos_none: VideoWhereInput
+  pictures_every: PictureWhereInput
+  pictures_some: PictureWhereInput
+  pictures_none: PictureWhereInput
   blocks_every: BlockInstanceWhereInput
   blocks_some: BlockInstanceWhereInput
   blocks_none: BlockInstanceWhereInput
@@ -940,8 +926,8 @@ type Exercise {
   id: ID!
   name: String!
   description: String
-  videos: [String!]!
-  pictures: [String!]!
+  videos(where: VideoWhereInput, orderBy: VideoOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): [Video!]
+  pictures(where: PictureWhereInput, orderBy: PictureOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): [Picture!]
   targets: [String!]!
   baseExercise: [String!]!
   parentExerciseInstances(where: ExerciseInstanceWhereInput, orderBy: ExerciseInstanceOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): [ExerciseInstance!]
@@ -961,8 +947,8 @@ input ExerciseCreateInput {
   id: ID
   name: String!
   description: String
-  videos: ExerciseCreatevideosInput
-  pictures: ExerciseCreatepicturesInput
+  videos: VideoCreateManyInput
+  pictures: PictureCreateManyInput
   targets: ExerciseCreatetargetsInput
   baseExercise: ExerciseCreatebaseExerciseInput
   parentExerciseInstances: ExerciseInstanceCreateManyWithoutExerciseInput
@@ -973,24 +959,16 @@ input ExerciseCreateOneWithoutParentExerciseInstancesInput {
   connect: ExerciseWhereUniqueInput
 }
 
-input ExerciseCreatepicturesInput {
-  set: [String!]
-}
-
 input ExerciseCreatetargetsInput {
   set: [String!]
 }
 
-input ExerciseCreatevideosInput {
-  set: [String!]
-}
-
 input ExerciseCreateWithoutParentExerciseInstancesInput {
   id: ID
   name: String!
   description: String
-  videos: ExerciseCreatevideosInput
-  pictures: ExerciseCreatepicturesInput
+  videos: VideoCreateManyInput
+  pictures: PictureCreateManyInput
   targets: ExerciseCreatetargetsInput
   baseExercise: ExerciseCreatebaseExerciseInput
 }
@@ -1295,8 +1273,6 @@ type ExercisePreviousValues {
   id: ID!
   name: String!
   description: String
-  videos: [String!]!
-  pictures: [String!]!
   targets: [String!]!
   baseExercise: [String!]!
 }
@@ -1326,8 +1302,8 @@ input ExerciseUpdatebaseExerciseInput {
 input ExerciseUpdateInput {
   name: String
   description: String
-  videos: ExerciseUpdatevideosInput
-  pictures: ExerciseUpdatepicturesInput
+  videos: VideoUpdateManyInput
+  pictures: PictureUpdateManyInput
   targets: ExerciseUpdatetargetsInput
   baseExercise: ExerciseUpdatebaseExerciseInput
   parentExerciseInstances: ExerciseInstanceUpdateManyWithoutExerciseInput
@@ -1336,8 +1312,6 @@ input ExerciseUpdateInput {
 input ExerciseUpdateManyMutationInput {
   name: String
   description: String
-  videos: ExerciseUpdatevideosInput
-  pictures: ExerciseUpdatepicturesInput
   targets: ExerciseUpdatetargetsInput
   baseExercise: ExerciseUpdatebaseExerciseInput
 }
@@ -1349,23 +1323,15 @@ input ExerciseUpdateOneRequiredWithoutParentExerciseInstancesInput {
   connect: ExerciseWhereUniqueInput
 }
 
-input ExerciseUpdatepicturesInput {
-  set: [String!]
-}
-
 input ExerciseUpdatetargetsInput {
   set: [String!]
 }
 
-input ExerciseUpdatevideosInput {
-  set: [String!]
-}
-
 input ExerciseUpdateWithoutParentExerciseInstancesDataInput {
   name: String
   description: String
-  videos: ExerciseUpdatevideosInput
-  pictures: ExerciseUpdatepicturesInput
+  videos: VideoUpdateManyInput
+  pictures: PictureUpdateManyInput
   targets: ExerciseUpdatetargetsInput
   baseExercise: ExerciseUpdatebaseExerciseInput
 }
@@ -1418,6 +1384,12 @@ input ExerciseWhereInput {
   description_not_starts_with: String
   description_ends_with: String
   description_not_ends_with: String
+  videos_every: VideoWhereInput
+  videos_some: VideoWhereInput
+  videos_none: VideoWhereInput
+  pictures_every: PictureWhereInput
+  pictures_some: PictureWhereInput
+  pictures_none: PictureWhereInput
   parentExerciseInstances_every: ExerciseInstanceWhereInput
   parentExerciseInstances_some: ExerciseInstanceWhereInput
   parentExerciseInstances_none: ExerciseInstanceWhereInput
@@ -1978,6 +1950,11 @@ input PictureCreateInput {
   link: String
 }
 
+input PictureCreateManyInput {
+  create: [PictureCreateInput!]
+  connect: [PictureWhereUniqueInput!]
+}
+
 input PictureCreateOneInput {
   create: PictureCreateInput
   connect: PictureWhereUniqueInput
@@ -2006,6 +1983,68 @@ type PicturePreviousValues {
   link: String
 }
 
+input PictureScalarWhereInput {
+  id: ID
+  id_not: ID
+  id_in: [ID!]
+  id_not_in: [ID!]
+  id_lt: ID
+  id_lte: ID
+  id_gt: ID
+  id_gte: ID
+  id_contains: ID
+  id_not_contains: ID
+  id_starts_with: ID
+  id_not_starts_with: ID
+  id_ends_with: ID
+  id_not_ends_with: ID
+  title: String
+  title_not: String
+  title_in: [String!]
+  title_not_in: [String!]
+  title_lt: String
+  title_lte: String
+  title_gt: String
+  title_gte: String
+  title_contains: String
+  title_not_contains: String
+  title_starts_with: String
+  title_not_starts_with: String
+  title_ends_with: String
+  title_not_ends_with: String
+  description: String
+  description_not: String
+  description_in: [String!]
+  description_not_in: [String!]
+  description_lt: String
+  description_lte: String
+  description_gt: String
+  description_gte: String
+  description_contains: String
+  description_not_contains: String
+  description_starts_with: String
+  description_not_starts_with: String
+  description_ends_with: String
+  description_not_ends_with: String
+  link: String
+  link_not: String
+  link_in: [String!]
+  link_not_in: [String!]
+  link_lt: String
+  link_lte: String
+  link_gt: String
+  link_gte: String
+  link_contains: String
+  link_not_contains: String
+  link_starts_with: String
+  link_not_starts_with: String
+  link_ends_with: String
+  link_not_ends_with: String
+  AND: [PictureScalarWhereInput!]
+  OR: [PictureScalarWhereInput!]
+  NOT: [PictureScalarWhereInput!]
+}
+
 type PictureSubscriptionPayload {
   mutation: MutationType!
   node: Picture
@@ -2038,12 +2077,35 @@ input PictureUpdateInput {
   link: String
 }
 
+input PictureUpdateManyDataInput {
+  title: String
+  description: String
+  link: String
+}
+
+input PictureUpdateManyInput {
+  create: [PictureCreateInput!]
+  update: [PictureUpdateWithWhereUniqueNestedInput!]
+  upsert: [PictureUpsertWithWhereUniqueNestedInput!]
+  delete: [PictureWhereUniqueInput!]
+  connect: [PictureWhereUniqueInput!]
+  set: [PictureWhereUniqueInput!]
+  disconnect: [PictureWhereUniqueInput!]
+  deleteMany: [PictureScalarWhereInput!]
+  updateMany: [PictureUpdateManyWithWhereNestedInput!]
+}
+
 input PictureUpdateManyMutationInput {
   title: String
   description: String
   link: String
 }
 
+input PictureUpdateManyWithWhereNestedInput {
+  where: PictureScalarWhereInput!
+  data: PictureUpdateManyDataInput!
+}
+
 input PictureUpdateOneInput {
   create: PictureCreateInput
   update: PictureUpdateDataInput
@@ -2053,11 +2115,22 @@ input PictureUpdateOneInput {
   connect: PictureWhereUniqueInput
 }
 
+input PictureUpdateWithWhereUniqueNestedInput {
+  where: PictureWhereUniqueInput!
+  data: PictureUpdateDataInput!
+}
+
 input PictureUpsertNestedInput {
   update: PictureUpdateDataInput!
   create: PictureCreateInput!
 }
 
+input PictureUpsertWithWhereUniqueNestedInput {
+  where: PictureWhereUniqueInput!
+  update: PictureUpdateDataInput!
+  create: PictureCreateInput!
+}
+
 input PictureWhereInput {
   id: ID
   id_not: ID
@@ -3602,6 +3675,11 @@ input VideoCreateInput {
   link: String
 }
 
+input VideoCreateManyInput {
+  create: [VideoCreateInput!]
+  connect: [VideoWhereUniqueInput!]
+}
+
 type VideoEdge {
   node: Video!
   cursor: String!
@@ -3628,6 +3706,76 @@ type VideoPreviousValues {
   link: String
 }
 
+input VideoScalarWhereInput {
+  id: ID
+  id_not: ID
+  id_in: [ID!]
+  id_not_in: [ID!]
+  id_lt: ID
+  id_lte: ID
+  id_gt: ID
+  id_gte: ID
+  id_contains: ID
+  id_not_contains: ID
+  id_starts_with: ID
+  id_not_starts_with: ID
+  id_ends_with: ID
+  id_not_ends_with: ID
+  title: String
+  title_not: String
+  title_in: [String!]
+  title_not_in: [String!]
+  title_lt: String
+  title_lte: String
+  title_gt: String
+  title_gte: String
+  title_contains: String
+  title_not_contains: String
+  title_starts_with: String
+  title_not_starts_with: String
+  title_ends_with: String
+  title_not_ends_with: String
+  description: String
+  description_not: String
+  description_in: [String!]
+  description_not_in: [String!]
+  description_lt: String
+  description_lte: String
+  description_gt: String
+  description_gte: String
+  description_contains: String
+  description_not_contains: String
+  description_starts_with: String
+  description_not_starts_with: String
+  description_ends_with: String
+  description_not_ends_with: String
+  duration: Int
+  duration_not: Int
+  duration_in: [Int!]
+  duration_not_in: [Int!]
+  duration_lt: Int
+  duration_lte: Int
+  duration_gt: Int
+  duration_gte: Int
+  link: String
+  link_not: String
+  link_in: [String!]
+  link_not_in: [String!]
+  link_lt: String
+  link_lte: String
+  link_gt: String
+  link_gte: String
+  link_contains: String
+  link_not_contains: String
+  link_starts_with: String
+  link_not_starts_with: String
+  link_ends_with: String
+  link_not_ends_with: String
+  AND: [VideoScalarWhereInput!]
+  OR: [VideoScalarWhereInput!]
+  NOT: [VideoScalarWhereInput!]
+}
+
 type VideoSubscriptionPayload {
   mutation: MutationType!
   node: Video
@@ -3646,6 +3794,14 @@ input VideoSubscriptionWhereInput {
   NOT: [VideoSubscriptionWhereInput!]
 }
 
+input VideoUpdateDataInput {
+  title: String
+  description: String
+  duration: Int
+  file: FileUpdateOneInput
+  link: String
+}
+
 input VideoUpdateInput {
   title: String
   description: String
@@ -3654,6 +3810,25 @@ input VideoUpdateInput {
   link: String
 }
 
+input VideoUpdateManyDataInput {
+  title: String
+  description: String
+  duration: Int
+  link: String
+}
+
+input VideoUpdateManyInput {
+  create: [VideoCreateInput!]
+  update: [VideoUpdateWithWhereUniqueNestedInput!]
+  upsert: [VideoUpsertWithWhereUniqueNestedInput!]
+  delete: [VideoWhereUniqueInput!]
+  connect: [VideoWhereUniqueInput!]
+  set: [VideoWhereUniqueInput!]
+  disconnect: [VideoWhereUniqueInput!]
+  deleteMany: [VideoScalarWhereInput!]
+  updateMany: [VideoUpdateManyWithWhereNestedInput!]
+}
+
 input VideoUpdateManyMutationInput {
   title: String
   description: String
@@ -3661,6 +3836,22 @@ input VideoUpdateManyMutationInput {
   link: String
 }
 
+input VideoUpdateManyWithWhereNestedInput {
+  where: VideoScalarWhereInput!
+  data: VideoUpdateManyDataInput!
+}
+
+input VideoUpdateWithWhereUniqueNestedInput {
+  where: VideoWhereUniqueInput!
+  data: VideoUpdateDataInput!
+}
+
+input VideoUpsertWithWhereUniqueNestedInput {
+  where: VideoWhereUniqueInput!
+  update: VideoUpdateDataInput!
+  create: VideoCreateInput!
+}
+
 input VideoWhereInput {
   id: ID
   id_not: ID

+ 512 - 89
backend/database/generated/prisma.graphql

@@ -1,5 +1,5 @@
 # source: http://prisma:4466
-# timestamp: Fri Apr 24 2020 14:04:06 GMT+0000 (Coordinated Universal Time)
+# timestamp: Sat Apr 25 2020 23:03:26 GMT+0000 (Coordinated Universal Time)
 
 type AggregateBlock {
   count: Int!
@@ -70,8 +70,8 @@ type Block implements Node {
   format: Format!
   rest: Int
   tracks(where: TrackWhereInput, orderBy: TrackOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): [Track!]
-  videos: [String!]!
-  pictures: [String!]!
+  videos(where: VideoWhereInput, orderBy: VideoOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): [Video!]
+  pictures(where: PictureWhereInput, orderBy: PictureOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): [Picture!]
   blocks(where: BlockInstanceWhereInput, orderBy: BlockInstanceOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): [BlockInstance!]
   parentBlockInstances(where: BlockInstanceWhereInput, orderBy: BlockInstanceOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): [BlockInstance!]
   exercises(where: ExerciseInstanceWhereInput, orderBy: ExerciseInstanceOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): [ExerciseInstance!]
@@ -93,10 +93,10 @@ input BlockCreateInput {
   description: String
   duration: Int
   rest: Int
-  videos: BlockCreatevideosInput
-  pictures: BlockCreatepicturesInput
   format: FormatCreateOneInput!
   tracks: TrackCreateManyInput
+  videos: VideoCreateManyInput
+  pictures: PictureCreateManyInput
   blocks: BlockInstanceCreateManyWithoutParentBlockInput
   parentBlockInstances: BlockInstanceCreateManyWithoutBlockInput
   exercises: ExerciseInstanceCreateManyWithoutParentBlockInstancesInput
@@ -117,24 +117,16 @@ input BlockCreateOneWithoutParentBlockInstancesInput {
   connect: BlockWhereUniqueInput
 }
 
-input BlockCreatepicturesInput {
-  set: [String!]
-}
-
-input BlockCreatevideosInput {
-  set: [String!]
-}
-
 input BlockCreateWithoutBlocksInput {
   id: ID
   title: String!
   description: String
   duration: Int
   rest: Int
-  videos: BlockCreatevideosInput
-  pictures: BlockCreatepicturesInput
   format: FormatCreateOneInput!
   tracks: TrackCreateManyInput
+  videos: VideoCreateManyInput
+  pictures: PictureCreateManyInput
   parentBlockInstances: BlockInstanceCreateManyWithoutBlockInput
   exercises: ExerciseInstanceCreateManyWithoutParentBlockInstancesInput
 }
@@ -145,10 +137,10 @@ input BlockCreateWithoutExercisesInput {
   description: String
   duration: Int
   rest: Int
-  videos: BlockCreatevideosInput
-  pictures: BlockCreatepicturesInput
   format: FormatCreateOneInput!
   tracks: TrackCreateManyInput
+  videos: VideoCreateManyInput
+  pictures: PictureCreateManyInput
   blocks: BlockInstanceCreateManyWithoutParentBlockInput
   parentBlockInstances: BlockInstanceCreateManyWithoutBlockInput
 }
@@ -159,10 +151,10 @@ input BlockCreateWithoutParentBlockInstancesInput {
   description: String
   duration: Int
   rest: Int
-  videos: BlockCreatevideosInput
-  pictures: BlockCreatepicturesInput
   format: FormatCreateOneInput!
   tracks: TrackCreateManyInput
+  videos: VideoCreateManyInput
+  pictures: PictureCreateManyInput
   blocks: BlockInstanceCreateManyWithoutParentBlockInput
   exercises: ExerciseInstanceCreateManyWithoutParentBlockInstancesInput
 }
@@ -727,8 +719,6 @@ type BlockPreviousValues {
   description: String
   duration: Int
   rest: Int
-  videos: [String!]!
-  pictures: [String!]!
 }
 
 type BlockSubscriptionPayload {
@@ -773,10 +763,10 @@ input BlockUpdateInput {
   description: String
   duration: Int
   rest: Int
-  videos: BlockUpdatevideosInput
-  pictures: BlockUpdatepicturesInput
   format: FormatUpdateOneRequiredInput
   tracks: TrackUpdateManyInput
+  videos: VideoUpdateManyInput
+  pictures: PictureUpdateManyInput
   blocks: BlockInstanceUpdateManyWithoutParentBlockInput
   parentBlockInstances: BlockInstanceUpdateManyWithoutBlockInput
   exercises: ExerciseInstanceUpdateManyWithoutParentBlockInstancesInput
@@ -787,8 +777,6 @@ input BlockUpdateManyMutationInput {
   description: String
   duration: Int
   rest: Int
-  videos: BlockUpdatevideosInput
-  pictures: BlockUpdatepicturesInput
 }
 
 input BlockUpdateOneRequiredWithoutExercisesInput {
@@ -814,23 +802,15 @@ input BlockUpdateOneWithoutBlocksInput {
   upsert: BlockUpsertWithoutBlocksInput
 }
 
-input BlockUpdatepicturesInput {
-  set: [String!]
-}
-
-input BlockUpdatevideosInput {
-  set: [String!]
-}
-
 input BlockUpdateWithoutBlocksDataInput {
   title: String
   description: String
   duration: Int
   rest: Int
-  videos: BlockUpdatevideosInput
-  pictures: BlockUpdatepicturesInput
   format: FormatUpdateOneRequiredInput
   tracks: TrackUpdateManyInput
+  videos: VideoUpdateManyInput
+  pictures: PictureUpdateManyInput
   parentBlockInstances: BlockInstanceUpdateManyWithoutBlockInput
   exercises: ExerciseInstanceUpdateManyWithoutParentBlockInstancesInput
 }
@@ -840,10 +820,10 @@ input BlockUpdateWithoutExercisesDataInput {
   description: String
   duration: Int
   rest: Int
-  videos: BlockUpdatevideosInput
-  pictures: BlockUpdatepicturesInput
   format: FormatUpdateOneRequiredInput
   tracks: TrackUpdateManyInput
+  videos: VideoUpdateManyInput
+  pictures: PictureUpdateManyInput
   blocks: BlockInstanceUpdateManyWithoutParentBlockInput
   parentBlockInstances: BlockInstanceUpdateManyWithoutBlockInput
 }
@@ -853,10 +833,10 @@ input BlockUpdateWithoutParentBlockInstancesDataInput {
   description: String
   duration: Int
   rest: Int
-  videos: BlockUpdatevideosInput
-  pictures: BlockUpdatepicturesInput
   format: FormatUpdateOneRequiredInput
   tracks: TrackUpdateManyInput
+  videos: VideoUpdateManyInput
+  pictures: PictureUpdateManyInput
   blocks: BlockInstanceUpdateManyWithoutParentBlockInput
   exercises: ExerciseInstanceUpdateManyWithoutParentBlockInstancesInput
 }
@@ -1053,6 +1033,12 @@ input BlockWhereInput {
   tracks_every: TrackWhereInput
   tracks_some: TrackWhereInput
   tracks_none: TrackWhereInput
+  videos_every: VideoWhereInput
+  videos_some: VideoWhereInput
+  videos_none: VideoWhereInput
+  pictures_every: PictureWhereInput
+  pictures_some: PictureWhereInput
+  pictures_none: PictureWhereInput
   blocks_every: BlockInstanceWhereInput
   blocks_some: BlockInstanceWhereInput
   blocks_none: BlockInstanceWhereInput
@@ -1444,8 +1430,8 @@ type Exercise implements Node {
   id: ID!
   name: String!
   description: String
-  videos: [String!]!
-  pictures: [String!]!
+  videos(where: VideoWhereInput, orderBy: VideoOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): [Video!]
+  pictures(where: PictureWhereInput, orderBy: PictureOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): [Picture!]
   targets: [String!]!
   baseExercise: [String!]!
   parentExerciseInstances(where: ExerciseInstanceWhereInput, orderBy: ExerciseInstanceOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): [ExerciseInstance!]
@@ -1469,10 +1455,10 @@ input ExerciseCreateInput {
   id: ID
   name: String!
   description: String
-  videos: ExerciseCreatevideosInput
-  pictures: ExerciseCreatepicturesInput
   targets: ExerciseCreatetargetsInput
   baseExercise: ExerciseCreatebaseExerciseInput
+  videos: VideoCreateManyInput
+  pictures: PictureCreateManyInput
   parentExerciseInstances: ExerciseInstanceCreateManyWithoutExerciseInput
 }
 
@@ -1481,26 +1467,18 @@ input ExerciseCreateOneWithoutParentExerciseInstancesInput {
   connect: ExerciseWhereUniqueInput
 }
 
-input ExerciseCreatepicturesInput {
-  set: [String!]
-}
-
 input ExerciseCreatetargetsInput {
   set: [String!]
 }
 
-input ExerciseCreatevideosInput {
-  set: [String!]
-}
-
 input ExerciseCreateWithoutParentExerciseInstancesInput {
   id: ID
   name: String!
   description: String
-  videos: ExerciseCreatevideosInput
-  pictures: ExerciseCreatepicturesInput
   targets: ExerciseCreatetargetsInput
   baseExercise: ExerciseCreatebaseExerciseInput
+  videos: VideoCreateManyInput
+  pictures: PictureCreateManyInput
 }
 
 """An edge in a connection."""
@@ -2004,8 +1982,6 @@ type ExercisePreviousValues {
   id: ID!
   name: String!
   description: String
-  videos: [String!]!
-  pictures: [String!]!
   targets: [String!]!
   baseExercise: [String!]!
 }
@@ -2054,18 +2030,16 @@ input ExerciseUpdatebaseExerciseInput {
 input ExerciseUpdateInput {
   name: String
   description: String
-  videos: ExerciseUpdatevideosInput
-  pictures: ExerciseUpdatepicturesInput
   targets: ExerciseUpdatetargetsInput
   baseExercise: ExerciseUpdatebaseExerciseInput
+  videos: VideoUpdateManyInput
+  pictures: PictureUpdateManyInput
   parentExerciseInstances: ExerciseInstanceUpdateManyWithoutExerciseInput
 }
 
 input ExerciseUpdateManyMutationInput {
   name: String
   description: String
-  videos: ExerciseUpdatevideosInput
-  pictures: ExerciseUpdatepicturesInput
   targets: ExerciseUpdatetargetsInput
   baseExercise: ExerciseUpdatebaseExerciseInput
 }
@@ -2077,25 +2051,17 @@ input ExerciseUpdateOneRequiredWithoutParentExerciseInstancesInput {
   upsert: ExerciseUpsertWithoutParentExerciseInstancesInput
 }
 
-input ExerciseUpdatepicturesInput {
-  set: [String!]
-}
-
 input ExerciseUpdatetargetsInput {
   set: [String!]
 }
 
-input ExerciseUpdatevideosInput {
-  set: [String!]
-}
-
 input ExerciseUpdateWithoutParentExerciseInstancesDataInput {
   name: String
   description: String
-  videos: ExerciseUpdatevideosInput
-  pictures: ExerciseUpdatepicturesInput
   targets: ExerciseUpdatetargetsInput
   baseExercise: ExerciseUpdatebaseExerciseInput
+  videos: VideoUpdateManyInput
+  pictures: PictureUpdateManyInput
 }
 
 input ExerciseUpsertWithoutParentExerciseInstancesInput {
@@ -2232,6 +2198,12 @@ input ExerciseWhereInput {
 
   """All values not ending with the given string."""
   description_not_ends_with: String
+  videos_every: VideoWhereInput
+  videos_some: VideoWhereInput
+  videos_none: VideoWhereInput
+  pictures_every: PictureWhereInput
+  pictures_some: PictureWhereInput
+  pictures_none: PictureWhereInput
   parentExerciseInstances_every: ExerciseInstanceWhereInput
   parentExerciseInstances_some: ExerciseInstanceWhereInput
   parentExerciseInstances_none: ExerciseInstanceWhereInput
@@ -3032,7 +3004,6 @@ type Mutation {
   createTraining(data: TrainingCreateInput!): Training!
   createBlock(data: BlockCreateInput!): Block!
   createBlockInstance(data: BlockInstanceCreateInput!): BlockInstance!
-  createVideo(data: VideoCreateInput!): Video!
   createExercise(data: ExerciseCreateInput!): Exercise!
   createExerciseInstance(data: ExerciseInstanceCreateInput!): ExerciseInstance!
   createComment(data: CommentCreateInput!): Comment!
@@ -3040,13 +3011,13 @@ type Mutation {
   createUser(data: UserCreateInput!): User!
   createTrack(data: TrackCreateInput!): Track!
   createFile(data: FileCreateInput!): File!
-  createPicture(data: PictureCreateInput!): Picture!
   createFormat(data: FormatCreateInput!): Format!
+  createPicture(data: PictureCreateInput!): Picture!
   createRating(data: RatingCreateInput!): Rating!
+  createVideo(data: VideoCreateInput!): Video!
   updateTraining(data: TrainingUpdateInput!, where: TrainingWhereUniqueInput!): Training
   updateBlock(data: BlockUpdateInput!, where: BlockWhereUniqueInput!): Block
   updateBlockInstance(data: BlockInstanceUpdateInput!, where: BlockInstanceWhereUniqueInput!): BlockInstance
-  updateVideo(data: VideoUpdateInput!, where: VideoWhereUniqueInput!): Video
   updateExercise(data: ExerciseUpdateInput!, where: ExerciseWhereUniqueInput!): Exercise
   updateExerciseInstance(data: ExerciseInstanceUpdateInput!, where: ExerciseInstanceWhereUniqueInput!): ExerciseInstance
   updateComment(data: CommentUpdateInput!, where: CommentWhereUniqueInput!): Comment
@@ -3054,13 +3025,13 @@ type Mutation {
   updateUser(data: UserUpdateInput!, where: UserWhereUniqueInput!): User
   updateTrack(data: TrackUpdateInput!, where: TrackWhereUniqueInput!): Track
   updateFile(data: FileUpdateInput!, where: FileWhereUniqueInput!): File
-  updatePicture(data: PictureUpdateInput!, where: PictureWhereUniqueInput!): Picture
   updateFormat(data: FormatUpdateInput!, where: FormatWhereUniqueInput!): Format
+  updatePicture(data: PictureUpdateInput!, where: PictureWhereUniqueInput!): Picture
   updateRating(data: RatingUpdateInput!, where: RatingWhereUniqueInput!): Rating
+  updateVideo(data: VideoUpdateInput!, where: VideoWhereUniqueInput!): Video
   deleteTraining(where: TrainingWhereUniqueInput!): Training
   deleteBlock(where: BlockWhereUniqueInput!): Block
   deleteBlockInstance(where: BlockInstanceWhereUniqueInput!): BlockInstance
-  deleteVideo(where: VideoWhereUniqueInput!): Video
   deleteExercise(where: ExerciseWhereUniqueInput!): Exercise
   deleteExerciseInstance(where: ExerciseInstanceWhereUniqueInput!): ExerciseInstance
   deleteComment(where: CommentWhereUniqueInput!): Comment
@@ -3068,13 +3039,13 @@ type Mutation {
   deleteUser(where: UserWhereUniqueInput!): User
   deleteTrack(where: TrackWhereUniqueInput!): Track
   deleteFile(where: FileWhereUniqueInput!): File
-  deletePicture(where: PictureWhereUniqueInput!): Picture
   deleteFormat(where: FormatWhereUniqueInput!): Format
+  deletePicture(where: PictureWhereUniqueInput!): Picture
   deleteRating(where: RatingWhereUniqueInput!): Rating
+  deleteVideo(where: VideoWhereUniqueInput!): Video
   upsertTraining(where: TrainingWhereUniqueInput!, create: TrainingCreateInput!, update: TrainingUpdateInput!): Training!
   upsertBlock(where: BlockWhereUniqueInput!, create: BlockCreateInput!, update: BlockUpdateInput!): Block!
   upsertBlockInstance(where: BlockInstanceWhereUniqueInput!, create: BlockInstanceCreateInput!, update: BlockInstanceUpdateInput!): BlockInstance!
-  upsertVideo(where: VideoWhereUniqueInput!, create: VideoCreateInput!, update: VideoUpdateInput!): Video!
   upsertExercise(where: ExerciseWhereUniqueInput!, create: ExerciseCreateInput!, update: ExerciseUpdateInput!): Exercise!
   upsertExerciseInstance(where: ExerciseInstanceWhereUniqueInput!, create: ExerciseInstanceCreateInput!, update: ExerciseInstanceUpdateInput!): ExerciseInstance!
   upsertComment(where: CommentWhereUniqueInput!, create: CommentCreateInput!, update: CommentUpdateInput!): Comment!
@@ -3082,13 +3053,13 @@ type Mutation {
   upsertUser(where: UserWhereUniqueInput!, create: UserCreateInput!, update: UserUpdateInput!): User!
   upsertTrack(where: TrackWhereUniqueInput!, create: TrackCreateInput!, update: TrackUpdateInput!): Track!
   upsertFile(where: FileWhereUniqueInput!, create: FileCreateInput!, update: FileUpdateInput!): File!
-  upsertPicture(where: PictureWhereUniqueInput!, create: PictureCreateInput!, update: PictureUpdateInput!): Picture!
   upsertFormat(where: FormatWhereUniqueInput!, create: FormatCreateInput!, update: FormatUpdateInput!): Format!
+  upsertPicture(where: PictureWhereUniqueInput!, create: PictureCreateInput!, update: PictureUpdateInput!): Picture!
   upsertRating(where: RatingWhereUniqueInput!, create: RatingCreateInput!, update: RatingUpdateInput!): Rating!
+  upsertVideo(where: VideoWhereUniqueInput!, create: VideoCreateInput!, update: VideoUpdateInput!): Video!
   updateManyTrainings(data: TrainingUpdateManyMutationInput!, where: TrainingWhereInput): BatchPayload!
   updateManyBlocks(data: BlockUpdateManyMutationInput!, where: BlockWhereInput): BatchPayload!
   updateManyBlockInstances(data: BlockInstanceUpdateManyMutationInput!, where: BlockInstanceWhereInput): BatchPayload!
-  updateManyVideos(data: VideoUpdateManyMutationInput!, where: VideoWhereInput): BatchPayload!
   updateManyExercises(data: ExerciseUpdateManyMutationInput!, where: ExerciseWhereInput): BatchPayload!
   updateManyExerciseInstances(data: ExerciseInstanceUpdateManyMutationInput!, where: ExerciseInstanceWhereInput): BatchPayload!
   updateManyComments(data: CommentUpdateManyMutationInput!, where: CommentWhereInput): BatchPayload!
@@ -3096,13 +3067,13 @@ type Mutation {
   updateManyUsers(data: UserUpdateManyMutationInput!, where: UserWhereInput): BatchPayload!
   updateManyTracks(data: TrackUpdateManyMutationInput!, where: TrackWhereInput): BatchPayload!
   updateManyFiles(data: FileUpdateManyMutationInput!, where: FileWhereInput): BatchPayload!
-  updateManyPictures(data: PictureUpdateManyMutationInput!, where: PictureWhereInput): BatchPayload!
   updateManyFormats(data: FormatUpdateManyMutationInput!, where: FormatWhereInput): BatchPayload!
+  updateManyPictures(data: PictureUpdateManyMutationInput!, where: PictureWhereInput): BatchPayload!
   updateManyRatings(data: RatingUpdateManyMutationInput!, where: RatingWhereInput): BatchPayload!
+  updateManyVideos(data: VideoUpdateManyMutationInput!, where: VideoWhereInput): BatchPayload!
   deleteManyTrainings(where: TrainingWhereInput): BatchPayload!
   deleteManyBlocks(where: BlockWhereInput): BatchPayload!
   deleteManyBlockInstances(where: BlockInstanceWhereInput): BatchPayload!
-  deleteManyVideos(where: VideoWhereInput): BatchPayload!
   deleteManyExercises(where: ExerciseWhereInput): BatchPayload!
   deleteManyExerciseInstances(where: ExerciseInstanceWhereInput): BatchPayload!
   deleteManyComments(where: CommentWhereInput): BatchPayload!
@@ -3110,9 +3081,10 @@ type Mutation {
   deleteManyUsers(where: UserWhereInput): BatchPayload!
   deleteManyTracks(where: TrackWhereInput): BatchPayload!
   deleteManyFiles(where: FileWhereInput): BatchPayload!
-  deleteManyPictures(where: PictureWhereInput): BatchPayload!
   deleteManyFormats(where: FormatWhereInput): BatchPayload!
+  deleteManyPictures(where: PictureWhereInput): BatchPayload!
   deleteManyRatings(where: RatingWhereInput): BatchPayload!
+  deleteManyVideos(where: VideoWhereInput): BatchPayload!
 }
 
 enum MutationType {
@@ -3173,6 +3145,11 @@ input PictureCreateInput {
   file: FileCreateOneInput
 }
 
+input PictureCreateManyInput {
+  create: [PictureCreateInput!]
+  connect: [PictureWhereUniqueInput!]
+}
+
 input PictureCreateOneInput {
   create: PictureCreateInput
   connect: PictureWhereUniqueInput
@@ -3205,6 +3182,177 @@ type PicturePreviousValues {
   link: String
 }
 
+input PictureScalarWhereInput {
+  """Logical AND on all given filters."""
+  AND: [PictureScalarWhereInput!]
+
+  """Logical OR on all given filters."""
+  OR: [PictureScalarWhereInput!]
+
+  """Logical NOT on all given filters combined by AND."""
+  NOT: [PictureScalarWhereInput!]
+  id: ID
+
+  """All values that are not equal to given value."""
+  id_not: ID
+
+  """All values that are contained in given list."""
+  id_in: [ID!]
+
+  """All values that are not contained in given list."""
+  id_not_in: [ID!]
+
+  """All values less than the given value."""
+  id_lt: ID
+
+  """All values less than or equal the given value."""
+  id_lte: ID
+
+  """All values greater than the given value."""
+  id_gt: ID
+
+  """All values greater than or equal the given value."""
+  id_gte: ID
+
+  """All values containing the given string."""
+  id_contains: ID
+
+  """All values not containing the given string."""
+  id_not_contains: ID
+
+  """All values starting with the given string."""
+  id_starts_with: ID
+
+  """All values not starting with the given string."""
+  id_not_starts_with: ID
+
+  """All values ending with the given string."""
+  id_ends_with: ID
+
+  """All values not ending with the given string."""
+  id_not_ends_with: ID
+  title: String
+
+  """All values that are not equal to given value."""
+  title_not: String
+
+  """All values that are contained in given list."""
+  title_in: [String!]
+
+  """All values that are not contained in given list."""
+  title_not_in: [String!]
+
+  """All values less than the given value."""
+  title_lt: String
+
+  """All values less than or equal the given value."""
+  title_lte: String
+
+  """All values greater than the given value."""
+  title_gt: String
+
+  """All values greater than or equal the given value."""
+  title_gte: String
+
+  """All values containing the given string."""
+  title_contains: String
+
+  """All values not containing the given string."""
+  title_not_contains: String
+
+  """All values starting with the given string."""
+  title_starts_with: String
+
+  """All values not starting with the given string."""
+  title_not_starts_with: String
+
+  """All values ending with the given string."""
+  title_ends_with: String
+
+  """All values not ending with the given string."""
+  title_not_ends_with: String
+  description: String
+
+  """All values that are not equal to given value."""
+  description_not: String
+
+  """All values that are contained in given list."""
+  description_in: [String!]
+
+  """All values that are not contained in given list."""
+  description_not_in: [String!]
+
+  """All values less than the given value."""
+  description_lt: String
+
+  """All values less than or equal the given value."""
+  description_lte: String
+
+  """All values greater than the given value."""
+  description_gt: String
+
+  """All values greater than or equal the given value."""
+  description_gte: String
+
+  """All values containing the given string."""
+  description_contains: String
+
+  """All values not containing the given string."""
+  description_not_contains: String
+
+  """All values starting with the given string."""
+  description_starts_with: String
+
+  """All values not starting with the given string."""
+  description_not_starts_with: String
+
+  """All values ending with the given string."""
+  description_ends_with: String
+
+  """All values not ending with the given string."""
+  description_not_ends_with: String
+  link: String
+
+  """All values that are not equal to given value."""
+  link_not: String
+
+  """All values that are contained in given list."""
+  link_in: [String!]
+
+  """All values that are not contained in given list."""
+  link_not_in: [String!]
+
+  """All values less than the given value."""
+  link_lt: String
+
+  """All values less than or equal the given value."""
+  link_lte: String
+
+  """All values greater than the given value."""
+  link_gt: String
+
+  """All values greater than or equal the given value."""
+  link_gte: String
+
+  """All values containing the given string."""
+  link_contains: String
+
+  """All values not containing the given string."""
+  link_not_contains: String
+
+  """All values starting with the given string."""
+  link_starts_with: String
+
+  """All values not starting with the given string."""
+  link_not_starts_with: String
+
+  """All values ending with the given string."""
+  link_ends_with: String
+
+  """All values not ending with the given string."""
+  link_not_ends_with: String
+}
+
 type PictureSubscriptionPayload {
   mutation: MutationType!
   node: Picture
@@ -3256,12 +3404,35 @@ input PictureUpdateInput {
   file: FileUpdateOneInput
 }
 
+input PictureUpdateManyDataInput {
+  title: String
+  description: String
+  link: String
+}
+
+input PictureUpdateManyInput {
+  create: [PictureCreateInput!]
+  connect: [PictureWhereUniqueInput!]
+  set: [PictureWhereUniqueInput!]
+  disconnect: [PictureWhereUniqueInput!]
+  delete: [PictureWhereUniqueInput!]
+  update: [PictureUpdateWithWhereUniqueNestedInput!]
+  updateMany: [PictureUpdateManyWithWhereNestedInput!]
+  deleteMany: [PictureScalarWhereInput!]
+  upsert: [PictureUpsertWithWhereUniqueNestedInput!]
+}
+
 input PictureUpdateManyMutationInput {
   title: String
   description: String
   link: String
 }
 
+input PictureUpdateManyWithWhereNestedInput {
+  where: PictureScalarWhereInput!
+  data: PictureUpdateManyDataInput!
+}
+
 input PictureUpdateOneInput {
   create: PictureCreateInput
   connect: PictureWhereUniqueInput
@@ -3271,11 +3442,22 @@ input PictureUpdateOneInput {
   upsert: PictureUpsertNestedInput
 }
 
+input PictureUpdateWithWhereUniqueNestedInput {
+  where: PictureWhereUniqueInput!
+  data: PictureUpdateDataInput!
+}
+
 input PictureUpsertNestedInput {
   update: PictureUpdateDataInput!
   create: PictureCreateInput!
 }
 
+input PictureUpsertWithWhereUniqueNestedInput {
+  where: PictureWhereUniqueInput!
+  update: PictureUpdateDataInput!
+  create: PictureCreateInput!
+}
+
 input PictureWhereInput {
   """Logical AND on all given filters."""
   AND: [PictureWhereInput!]
@@ -3456,7 +3638,6 @@ type Query {
   trainings(where: TrainingWhereInput, orderBy: TrainingOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): [Training]!
   blocks(where: BlockWhereInput, orderBy: BlockOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): [Block]!
   blockInstances(where: BlockInstanceWhereInput, orderBy: BlockInstanceOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): [BlockInstance]!
-  videos(where: VideoWhereInput, orderBy: VideoOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): [Video]!
   exercises(where: ExerciseWhereInput, orderBy: ExerciseOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): [Exercise]!
   exerciseInstances(where: ExerciseInstanceWhereInput, orderBy: ExerciseInstanceOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): [ExerciseInstance]!
   comments(where: CommentWhereInput, orderBy: CommentOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): [Comment]!
@@ -3464,13 +3645,13 @@ type Query {
   users(where: UserWhereInput, orderBy: UserOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): [User]!
   tracks(where: TrackWhereInput, orderBy: TrackOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): [Track]!
   files(where: FileWhereInput, orderBy: FileOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): [File]!
-  pictures(where: PictureWhereInput, orderBy: PictureOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): [Picture]!
   formats(where: FormatWhereInput, orderBy: FormatOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): [Format]!
+  pictures(where: PictureWhereInput, orderBy: PictureOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): [Picture]!
   ratings(where: RatingWhereInput, orderBy: RatingOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): [Rating]!
+  videos(where: VideoWhereInput, orderBy: VideoOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): [Video]!
   training(where: TrainingWhereUniqueInput!): Training
   block(where: BlockWhereUniqueInput!): Block
   blockInstance(where: BlockInstanceWhereUniqueInput!): BlockInstance
-  video(where: VideoWhereUniqueInput!): Video
   exercise(where: ExerciseWhereUniqueInput!): Exercise
   exerciseInstance(where: ExerciseInstanceWhereUniqueInput!): ExerciseInstance
   comment(where: CommentWhereUniqueInput!): Comment
@@ -3478,13 +3659,13 @@ type Query {
   user(where: UserWhereUniqueInput!): User
   track(where: TrackWhereUniqueInput!): Track
   file(where: FileWhereUniqueInput!): File
-  picture(where: PictureWhereUniqueInput!): Picture
   format(where: FormatWhereUniqueInput!): Format
+  picture(where: PictureWhereUniqueInput!): Picture
   rating(where: RatingWhereUniqueInput!): Rating
+  video(where: VideoWhereUniqueInput!): Video
   trainingsConnection(where: TrainingWhereInput, orderBy: TrainingOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): TrainingConnection!
   blocksConnection(where: BlockWhereInput, orderBy: BlockOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): BlockConnection!
   blockInstancesConnection(where: BlockInstanceWhereInput, orderBy: BlockInstanceOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): BlockInstanceConnection!
-  videosConnection(where: VideoWhereInput, orderBy: VideoOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): VideoConnection!
   exercisesConnection(where: ExerciseWhereInput, orderBy: ExerciseOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): ExerciseConnection!
   exerciseInstancesConnection(where: ExerciseInstanceWhereInput, orderBy: ExerciseInstanceOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): ExerciseInstanceConnection!
   commentsConnection(where: CommentWhereInput, orderBy: CommentOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): CommentConnection!
@@ -3492,9 +3673,10 @@ type Query {
   usersConnection(where: UserWhereInput, orderBy: UserOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): UserConnection!
   tracksConnection(where: TrackWhereInput, orderBy: TrackOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): TrackConnection!
   filesConnection(where: FileWhereInput, orderBy: FileOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): FileConnection!
-  picturesConnection(where: PictureWhereInput, orderBy: PictureOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): PictureConnection!
   formatsConnection(where: FormatWhereInput, orderBy: FormatOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): FormatConnection!
+  picturesConnection(where: PictureWhereInput, orderBy: PictureOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): PictureConnection!
   ratingsConnection(where: RatingWhereInput, orderBy: RatingOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): RatingConnection!
+  videosConnection(where: VideoWhereInput, orderBy: VideoOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): VideoConnection!
 
   """Fetches an object given its ID"""
   node(
@@ -3965,7 +4147,6 @@ type Subscription {
   training(where: TrainingSubscriptionWhereInput): TrainingSubscriptionPayload
   block(where: BlockSubscriptionWhereInput): BlockSubscriptionPayload
   blockInstance(where: BlockInstanceSubscriptionWhereInput): BlockInstanceSubscriptionPayload
-  video(where: VideoSubscriptionWhereInput): VideoSubscriptionPayload
   exercise(where: ExerciseSubscriptionWhereInput): ExerciseSubscriptionPayload
   exerciseInstance(where: ExerciseInstanceSubscriptionWhereInput): ExerciseInstanceSubscriptionPayload
   comment(where: CommentSubscriptionWhereInput): CommentSubscriptionPayload
@@ -3973,9 +4154,10 @@ type Subscription {
   user(where: UserSubscriptionWhereInput): UserSubscriptionPayload
   track(where: TrackSubscriptionWhereInput): TrackSubscriptionPayload
   file(where: FileSubscriptionWhereInput): FileSubscriptionPayload
-  picture(where: PictureSubscriptionWhereInput): PictureSubscriptionPayload
   format(where: FormatSubscriptionWhereInput): FormatSubscriptionPayload
+  picture(where: PictureSubscriptionWhereInput): PictureSubscriptionPayload
   rating(where: RatingSubscriptionWhereInput): RatingSubscriptionPayload
+  video(where: VideoSubscriptionWhereInput): VideoSubscriptionPayload
 }
 
 type Track implements Node {
@@ -6025,6 +6207,11 @@ input VideoCreateInput {
   file: FileCreateOneInput
 }
 
+input VideoCreateManyInput {
+  create: [VideoCreateInput!]
+  connect: [VideoWhereUniqueInput!]
+}
+
 """An edge in a connection."""
 type VideoEdge {
   """The item at the end of the edge."""
@@ -6055,6 +6242,199 @@ type VideoPreviousValues {
   link: String
 }
 
+input VideoScalarWhereInput {
+  """Logical AND on all given filters."""
+  AND: [VideoScalarWhereInput!]
+
+  """Logical OR on all given filters."""
+  OR: [VideoScalarWhereInput!]
+
+  """Logical NOT on all given filters combined by AND."""
+  NOT: [VideoScalarWhereInput!]
+  id: ID
+
+  """All values that are not equal to given value."""
+  id_not: ID
+
+  """All values that are contained in given list."""
+  id_in: [ID!]
+
+  """All values that are not contained in given list."""
+  id_not_in: [ID!]
+
+  """All values less than the given value."""
+  id_lt: ID
+
+  """All values less than or equal the given value."""
+  id_lte: ID
+
+  """All values greater than the given value."""
+  id_gt: ID
+
+  """All values greater than or equal the given value."""
+  id_gte: ID
+
+  """All values containing the given string."""
+  id_contains: ID
+
+  """All values not containing the given string."""
+  id_not_contains: ID
+
+  """All values starting with the given string."""
+  id_starts_with: ID
+
+  """All values not starting with the given string."""
+  id_not_starts_with: ID
+
+  """All values ending with the given string."""
+  id_ends_with: ID
+
+  """All values not ending with the given string."""
+  id_not_ends_with: ID
+  title: String
+
+  """All values that are not equal to given value."""
+  title_not: String
+
+  """All values that are contained in given list."""
+  title_in: [String!]
+
+  """All values that are not contained in given list."""
+  title_not_in: [String!]
+
+  """All values less than the given value."""
+  title_lt: String
+
+  """All values less than or equal the given value."""
+  title_lte: String
+
+  """All values greater than the given value."""
+  title_gt: String
+
+  """All values greater than or equal the given value."""
+  title_gte: String
+
+  """All values containing the given string."""
+  title_contains: String
+
+  """All values not containing the given string."""
+  title_not_contains: String
+
+  """All values starting with the given string."""
+  title_starts_with: String
+
+  """All values not starting with the given string."""
+  title_not_starts_with: String
+
+  """All values ending with the given string."""
+  title_ends_with: String
+
+  """All values not ending with the given string."""
+  title_not_ends_with: String
+  description: String
+
+  """All values that are not equal to given value."""
+  description_not: String
+
+  """All values that are contained in given list."""
+  description_in: [String!]
+
+  """All values that are not contained in given list."""
+  description_not_in: [String!]
+
+  """All values less than the given value."""
+  description_lt: String
+
+  """All values less than or equal the given value."""
+  description_lte: String
+
+  """All values greater than the given value."""
+  description_gt: String
+
+  """All values greater than or equal the given value."""
+  description_gte: String
+
+  """All values containing the given string."""
+  description_contains: String
+
+  """All values not containing the given string."""
+  description_not_contains: String
+
+  """All values starting with the given string."""
+  description_starts_with: String
+
+  """All values not starting with the given string."""
+  description_not_starts_with: String
+
+  """All values ending with the given string."""
+  description_ends_with: String
+
+  """All values not ending with the given string."""
+  description_not_ends_with: String
+  duration: Int
+
+  """All values that are not equal to given value."""
+  duration_not: Int
+
+  """All values that are contained in given list."""
+  duration_in: [Int!]
+
+  """All values that are not contained in given list."""
+  duration_not_in: [Int!]
+
+  """All values less than the given value."""
+  duration_lt: Int
+
+  """All values less than or equal the given value."""
+  duration_lte: Int
+
+  """All values greater than the given value."""
+  duration_gt: Int
+
+  """All values greater than or equal the given value."""
+  duration_gte: Int
+  link: String
+
+  """All values that are not equal to given value."""
+  link_not: String
+
+  """All values that are contained in given list."""
+  link_in: [String!]
+
+  """All values that are not contained in given list."""
+  link_not_in: [String!]
+
+  """All values less than the given value."""
+  link_lt: String
+
+  """All values less than or equal the given value."""
+  link_lte: String
+
+  """All values greater than the given value."""
+  link_gt: String
+
+  """All values greater than or equal the given value."""
+  link_gte: String
+
+  """All values containing the given string."""
+  link_contains: String
+
+  """All values not containing the given string."""
+  link_not_contains: String
+
+  """All values starting with the given string."""
+  link_starts_with: String
+
+  """All values not starting with the given string."""
+  link_not_starts_with: String
+
+  """All values ending with the given string."""
+  link_ends_with: String
+
+  """All values not ending with the given string."""
+  link_not_ends_with: String
+}
+
 type VideoSubscriptionPayload {
   mutation: MutationType!
   node: Video
@@ -6092,6 +6472,14 @@ input VideoSubscriptionWhereInput {
   node: VideoWhereInput
 }
 
+input VideoUpdateDataInput {
+  title: String
+  description: String
+  duration: Int
+  link: String
+  file: FileUpdateOneInput
+}
+
 input VideoUpdateInput {
   title: String
   description: String
@@ -6100,6 +6488,25 @@ input VideoUpdateInput {
   file: FileUpdateOneInput
 }
 
+input VideoUpdateManyDataInput {
+  title: String
+  description: String
+  duration: Int
+  link: String
+}
+
+input VideoUpdateManyInput {
+  create: [VideoCreateInput!]
+  connect: [VideoWhereUniqueInput!]
+  set: [VideoWhereUniqueInput!]
+  disconnect: [VideoWhereUniqueInput!]
+  delete: [VideoWhereUniqueInput!]
+  update: [VideoUpdateWithWhereUniqueNestedInput!]
+  updateMany: [VideoUpdateManyWithWhereNestedInput!]
+  deleteMany: [VideoScalarWhereInput!]
+  upsert: [VideoUpsertWithWhereUniqueNestedInput!]
+}
+
 input VideoUpdateManyMutationInput {
   title: String
   description: String
@@ -6107,6 +6514,22 @@ input VideoUpdateManyMutationInput {
   link: String
 }
 
+input VideoUpdateManyWithWhereNestedInput {
+  where: VideoScalarWhereInput!
+  data: VideoUpdateManyDataInput!
+}
+
+input VideoUpdateWithWhereUniqueNestedInput {
+  where: VideoWhereUniqueInput!
+  data: VideoUpdateDataInput!
+}
+
+input VideoUpsertWithWhereUniqueNestedInput {
+  where: VideoWhereUniqueInput!
+  update: VideoUpdateDataInput!
+  create: VideoCreateInput!
+}
+
 input VideoWhereInput {
   """Logical AND on all given filters."""
   AND: [VideoWhereInput!]

+ 22 - 0
backend/package-lock.json

@@ -1865,6 +1865,14 @@
         "@types/range-parser": "*"
       }
     },
+    "@types/fluent-ffmpeg": {
+      "version": "2.1.14",
+      "resolved": "https://registry.npmjs.org/@types/fluent-ffmpeg/-/fluent-ffmpeg-2.1.14.tgz",
+      "integrity": "sha512-nJrAX9ODNI7mUB0b7Y0Stx1a6dOpV3zXsOnWoBuEd9/woQhepBNCMeCyOL6SLJD3jn5sLw5ciDGH0RwJenCoag==",
+      "requires": {
+        "@types/node": "*"
+      }
+    },
     "@types/form-data": {
       "version": "0.0.33",
       "resolved": "https://registry.npmjs.org/@types/form-data/-/form-data-0.0.33.tgz",
@@ -1963,6 +1971,11 @@
         "@types/koa": "*"
       }
     },
+    "@types/lodash": {
+      "version": "4.14.150",
+      "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.150.tgz",
+      "integrity": "sha512-kMNLM5JBcasgYscD9x/Gvr6lTAv2NVgsKtet/hm93qMyf/D1pt+7jeEZklKJKxMVmXjxbRVQQGfqDSfipYCO6w=="
+    },
     "@types/long": {
       "version": "4.0.1",
       "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.1.tgz",
@@ -5381,6 +5394,15 @@
       "resolved": "https://registry.npmjs.org/flow-bin/-/flow-bin-0.87.0.tgz",
       "integrity": "sha512-mnvBXXZkUp4y6A96bR5BHa3q1ioIIN2L10w5osxJqagAakTXFYZwjl0t9cT3y2aCEf1wnK6n91xgYypQS/Dqbw=="
     },
+    "fluent-ffmpeg": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/fluent-ffmpeg/-/fluent-ffmpeg-2.1.2.tgz",
+      "integrity": "sha1-yVLeIkD4EuvaCqgAbXd27irPfXQ=",
+      "requires": {
+        "async": ">=0.2.9",
+        "which": "^1.1.1"
+      }
+    },
     "for-in": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz",

+ 4 - 0
backend/package.json

@@ -17,7 +17,9 @@
   "dependencies": {
     "@types/bcryptjs": "^2.4.2",
     "@types/cookie-parser": "^1.4.2",
+    "@types/fluent-ffmpeg": "^2.1.14",
     "@types/jsonwebtoken": "^8.3.9",
+    "@types/lodash": "^4.14.150",
     "@types/randombytes": "^2.0.0",
     "@types/sharp": "^0.24.0",
     "apollo-server-express": "^2.12.0",
@@ -27,8 +29,10 @@
     "cors": "2.8.5",
     "date-fns": "2.11.1",
     "dotenv": "8.2.0",
+    "fluent-ffmpeg": "^2.1.2",
     "graphql": "^14.6.0",
     "jsonwebtoken": "^8.5.1",
+    "lodash": "^4.17.15",
     "nodemailer": "^6.4.6",
     "prisma": "^1.34.10",
     "prisma-binding": "2.3.16",

+ 20 - 3
backend/schema.graphql

@@ -50,7 +50,19 @@ type Query {
 
   # File module
   fsFiles(directory: String!): [FsFile!]!
-  files: [File!]!
+  files(
+    where: FileWhereInput
+    orderBy: FileOrderByInput
+    skip: Int
+    after: String
+    before: String
+    first: Int
+    last: Int
+  ): [File!]!
+  videos: [Video!]!
+  video(where: VideoWhereUniqueInput): Video!
+
+  # User module
   currentUser: User!
   user(where: UserWhereUniqueInput!): User
   users(
@@ -107,11 +119,16 @@ type Query {
 type Mutation {
   # File module
   uploadFile(file: Upload!, comment: String): File!
+  createVideo(data: VideoCreateInput): Video!
+  updateVideo(data: VideoUpdateInput!, where: VideoWhereUniqueInput!): Video!
+  createVideo(data: VideoCreateInput): Video!
+  updateVideo(data: VideoUpdateInput!, where: VideoWhereUniqueInput!): Video!
 
   # User module
   createUser(data: UserCreateInput!): User!
-  updateUser(email: String!, data: UserUpdateInput!): User
-  deleteUser(email: String!): User
+  updateUser(id: ID!, data: UserUpdateInput!): User!
+  deleteUser(id: ID!): User!
+  updatePermissions(id: ID!, permissions: [Permission!]!): User!
 
   # Training module
   createTraining(

+ 0 - 1
backend/src/file/constants.ts

@@ -1,3 +1,2 @@
 export const tmpDir = 'upload_files/tmp'
 export const uploadDir = 'upload_files'
-export const thumbnails = 'thumbnails'

+ 48 - 8
backend/src/file/resolvers.ts

@@ -2,24 +2,33 @@ import { IResolvers } from 'apollo-server-express'
 import fs from 'fs'
 import randombytes from 'randombytes'
 import sharp from 'sharp'
-import { tmpDir, uploadDir, thumbnails } from './constants'
-import user from '../user'
+import ffmpeg from 'fluent-ffmpeg'
+import { tmpDir, uploadDir } from './constants'
+import { checkPermission } from '../user/resolvers'
 
 export const resolvers: IResolvers = {
   Query: {
     fsFiles: async (parent, { directory }, context, info) => {
-      user.checkPermission(context, 'ADMIN')
+      checkPermission(context, 'ADMIN')
       const data = await fsFiles(directory)
       return data
     },
     files: (parent, args, context, info) => {
-      user.checkPermission(context, 'ADMIN')
+      checkPermission(context, 'ADMIN')
       return context.db.query.files(args, info)
     },
+    videos: (parent, args, context, info) => {
+      checkPermission(context)
+      return context.db.query.videos(args, info)
+    },
+    video: (parent, args, context, info) => {
+      checkPermission(context, 'ADMIN')
+      return context.db.query.video(args, info)
+    },
   },
   Mutation: {
     uploadFile: async (parent, { comment, file }, context, info) => {
-      user.checkPermission(context, 'ADMIN')
+      checkPermission(context, 'ADMIN')
       const fileInfo = await uploadFile(file)
 
       return context.db.mutation.createFile(
@@ -33,6 +42,14 @@ export const resolvers: IResolvers = {
         info
       )
     },
+    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)
+    },
   },
 }
 
@@ -60,7 +77,9 @@ async function uploadFile(file: any) {
   const fsFilename = randombytes(16).toString('hex')
   const tmpPath = `${tmpDir}/${fsFilename}`
   const path = `${uploadDir}/${fsFilename}`
-  const thumbnailPath = `${thumbnails}/${fsFilename}`
+  let thumbnail = null
+  const thumbnailFile = `thmb${fsFilename}`
+  const thumbnailPath = `${uploadDir}/${thumbnailFile}`
   await new Promise((resolve, reject) => {
     const file = fs.createWriteStream(tmpPath)
     file.on('finish', resolve)
@@ -79,6 +98,27 @@ async function uploadFile(file: any) {
       await processImage(tmpPath, path)
       await createThumbnail(tmpPath, thumbnailPath)
       await fs.promises.unlink(tmpPath)
+      thumbnail = thumbnailPath
+    } catch (error) {
+      try {
+        await fs.promises.unlink(tmpPath)
+        await fs.promises.unlink(path)
+      } catch (ignore) {}
+      throw error
+    }
+  } else if (mimetype.startsWith('video/')) {
+    console.log('video')
+    try {
+      ffmpeg(path)
+        .screenshot({
+          timestamps: ['25%'],
+          filename: thumbnailFile,
+          folder: uploadDir,
+          size: '200x?',
+        })
+        .on('end', () => fs.promises.rename(`${thumbnailPath}.png`, thumbnailPath))
+      await fs.promises.rename(tmpPath, path)
+      thumbnail = thumbnailPath
     } catch (error) {
       try {
         await fs.promises.unlink(tmpPath)
@@ -89,7 +129,6 @@ async function uploadFile(file: any) {
   } else {
     console.log('no image')
     try {
-      console.log(tmpPath)
       await fs.promises.rename(tmpPath, path)
     } catch (error) {
       try {
@@ -103,9 +142,10 @@ async function uploadFile(file: any) {
   const { size } = await fs.promises.stat(path)
 
   return {
-    filename,
     path,
     mimetype,
+    thumbnail,
+    filename,
     encoding,
     size,
   }

ファイルの差分が大きいため隠しています
+ 770 - 58
backend/src/gql/resolvers.ts


+ 15 - 4
backend/src/user/resolvers.ts

@@ -138,7 +138,7 @@ export const resolvers: IResolvers = {
         info
       )
     },
-    updateUser: async (parent, { email, password, ...args }, context, info) => {
+    updateUser: async (parent, { id, email, password, ...args }, context, info) => {
       checkPermission(context, 'ADMIN')
       const updatedData = { ...args }
       if (!!email) updatedData.email = email.toLowerCase()
@@ -148,14 +148,25 @@ export const resolvers: IResolvers = {
           data: {
             ...updatedData,
           },
-          where: { email },
+          where: { id },
         },
         info
       )
     },
-    deleteUser: (parent, { email }, context, info) => {
+    deleteUser: (parent, { id }, context, info) => {
       checkPermission(context, 'ADMIN')
-      return context.db.mutation.deleteUser({ where: { email } })
+      return context.db.mutation.deleteUser({ where: { id } })
+    },
+    updatePermissions: (parent, { id, permissions }, context, info) => {
+      checkPermission(context, 'ADMIN')
+      return context.db.mutation.updateUser({
+        where: { id },
+        data: {
+          permissions: {
+            set: permissions,
+          },
+        },
+      })
     },
   },
 }

+ 3 - 3
frontend/Dockerfile

@@ -1,4 +1,4 @@
-FROM node:alpine
+FROM node:lts-alpine
 
 WORKDIR /app
 
@@ -10,8 +10,8 @@ RUN rm -rf node_modules/*
 RUN rm -rf node_modules/.bin
 RUN rm -rf node_modules/.cache
 RUN rm -f package-lock.json
-RUN npm install typescript @types/react --silent
 RUN npm install react-scripts -g --silent
-RUN npm install --silent
+#RUN npm install typescript @types/react --silent
+#RUN npm install --silent
 
 CMD ["npm", "run", "dev"]

+ 3 - 4
frontend/jest.config.js

@@ -1,11 +1,10 @@
 module.exports = {
-  //testEnvironment: 'node',
+  testEnvironment: 'node',
   preset: 'ts-jest',
-  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
+  setupFiles: ['<rootDir>/jest.setup.js'],
   transform: { '^.+\\.(t|j)sx?$': 'ts-jest' },
   testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$',
   moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
   testPathIgnorePatterns: ['<rootDir>/.next/', '<rootDir>/node_modules/'],
-  globals: { 'ts-jest': { 'ts-config': '<rootDir>/tsconfig.jest.json' } }
+  globals: { 'ts-jest': { 'ts-config': '<rootDir>/tsconfig.jest.json' } },
 }
-console.log('loaded.')

ファイルの差分が大きいため隠しています
+ 216 - 554
frontend/package-lock.json


+ 8 - 8
frontend/package.json

@@ -6,20 +6,20 @@
     "dev": "next dev",
     "build": "next build",
     "start": "next start",
-    "test": "jest",
+    "test": "NODE_ENV=test jest",
     "test:watch": "npm run test -- --watchAll",
     "test:coverage": "npm run test -- --coverage",
     "type-check": "tsc"
   },
   "dependencies": {
     "@apollo/client": "3.0.0-beta.43",
-    "@apollo/react-ssr": "^3.1.4",
+    "@apollo/react-ssr": "^3.1.5",
     "@fortawesome/fontawesome-svg-core": "^1.2.28",
     "@fortawesome/free-solid-svg-icons": "^5.13.0",
     "@fortawesome/react-fontawesome": "^0.1.9",
     "@types/apollo-upload-client": "^8.1.3",
     "@types/howler": "^2.1.2",
-    "@types/lodash": "^4.14.149",
+    "@types/lodash": "^4.14.150",
     "@types/react-onclickoutside": "^6.7.3",
     "@types/styled-jsx": "^2.2.8",
     "@types/video.js": "7.3.7",
@@ -36,7 +36,7 @@
     "howler": "^2.1.3",
     "isomorphic-unfetch": "^3.0.0",
     "lodash": "^4.17.15",
-    "next": "^9.3.4",
+    "next": "^9.3.5",
     "next-link": "^2.0.0",
     "normalize.css": "^8.0.1",
     "nprogress": "^0.2.0",
@@ -46,7 +46,7 @@
     "react-sortable-hoc": "^1.11.0",
     "standard": "14.3.3",
     "video.js": "^7.7.5",
-    "yup": "^0.28.3"
+    "yup": "^0.28.4"
   },
   "devDependencies": {
     "@apollo/react-testing": "^3.1.4",
@@ -61,14 +61,14 @@
     "@types/yup": "0.26.35",
     "@zeit/next-typescript": "^1.1.1",
     "babel-eslint": "10.1.0",
-    "babel-jest": "^25.3.0",
+    "babel-jest": "^25.4.0",
     "enzyme": "^3.11.0",
     "enzyme-adapter-react-16": "^1.15.2",
-    "jest": "^25.3.0",
+    "jest": "^25.4.0",
     "jest-transform-graphql": "^2.1.0",
     "npm-check-updates": "^4.1.2",
     "react-test-renderer": "16.13.1",
-    "ts-jest": "^25.3.1",
+    "ts-jest": "^25.4.0",
     "typescript": "3.8.3"
   }
 }

+ 30 - 3
frontend/pages/admin/block/index.tsx

@@ -2,11 +2,38 @@ import { useBlocksQuery } from '../../../src/gql'
 import { FunctionComponent } from 'react'
 import { TBlock } from '../../../src/training/types'
 import { AdminList } from '../../../src/app'
+import theme from '../../../src/styles/theme'
 
-const AdminBlock: FunctionComponent<{ item: TBlock }> = ({ item }) => {
+const AdminBlock: FunctionComponent<{ item: TBlock; className?: string }> = ({
+  item,
+  className,
+}) => {
   return (
-    <div>
-      {item.id} {item.title}
+    <div className={className}>
+      <div className='admin-training-title'>{item.title}</div>{' '}
+      <div className='admin-training-description'>{item.description}</div>
+      <div className='admin-training-info'>{item.format.name}</div>
+      <style jsx>{`
+        .${className} {
+          display: flex;
+        }
+        .admin-training-title {
+          width: 15%;
+        }
+        .admin-training-description {
+          width: 65%;
+        }
+        .admin-training-info {
+          width: 20%;
+          text-align: right;
+        }
+        button {
+          margin: 0 0.4em;
+          padding: 0;
+          color: ${theme.colors.buttonBackground};
+          background-color: transparent;
+        }
+      `}</style>
     </div>
   )
 }

+ 30 - 3
frontend/pages/admin/exercise/index.tsx

@@ -2,11 +2,38 @@ import { useExercisesQuery } from '../../../src/gql'
 import { FunctionComponent } from 'react'
 import { TExercise } from '../../../src/training/types'
 import { AdminList } from '../../../src/app'
+import theme from '../../../src/styles/theme'
 
-const AdminExercise: FunctionComponent<{ item: TExercise }> = ({ item }) => {
+const AdminExercise: FunctionComponent<{ item: TExercise; className?: string }> = ({
+  item,
+  className,
+}) => {
   return (
-    <div>
-      {item.id} {item.name}
+    <div className={className}>
+      <div className='admin-training-title'>{item.name}</div>{' '}
+      <div className='admin-training-description'>{item.description}</div>
+      <div className='admin-training-info'>{item.parentExerciseInstances?.length}</div>
+      <style jsx>{`
+        .${className} {
+          display: flex;
+        }
+        .admin-training-title {
+          width: 15%;
+        }
+        .admin-training-description {
+          width: 65%;
+        }
+        .admin-training-info {
+          width: 20%;
+          text-align: right;
+        }
+        button {
+          margin: 0 0.4em;
+          padding: 0;
+          color: ${theme.colors.buttonBackground};
+          background-color: transparent;
+        }
+      `}</style>
     </div>
   )
 }

+ 0 - 0
frontend/pages/admin/file/create.tsx


+ 1 - 20
frontend/pages/admin/training/[id].tsx

@@ -1,22 +1,3 @@
-import { useRouter } from 'next/router'
-import EditTraining from '../../../src/training/components/EditTraining'
-import { useTrainingQuery } from '../../../src/gql'
-import { AdminPage } from '../../../src/app'
-
-const EditTrainingPage = () => {
-  const router = useRouter()
-  const { id } = router.query
-  const { data, error, loading } = useTrainingQuery({
-    variables: { id: typeof id === 'string' ? id : id[0] },
-  })
-
-  let content
-  if (loading) content = <p>Loading data...</p>
-  if (error) content = <p>Error loading data.</p>
-  if (data?.training) content = <EditTraining training={data.training} />
-  else content = <p>Training {id} not found.</p>
-
-  return <AdminPage>{content}</AdminPage>
-}
+import EditTrainingPage from './create'
 
 export default EditTrainingPage

+ 17 - 9
frontend/pages/admin/training/create.tsx

@@ -1,14 +1,22 @@
+import { useRouter } from 'next/router'
 import EditTraining from '../../../src/training/components/EditTraining'
-import { emptyTraining } from '../../../src/training/utils'
+import { useTrainingQuery } from '../../../src/gql'
 import { AdminPage } from '../../../src/app'
+import { emptyTraining } from '../../../src/training/utils'
+
+const EditTrainingPage = () => {
+  const router = useRouter()
+  const { id } = router.query
+
+  const { data = { training: emptyTraining() }, error = undefined, loading = false } =
+    typeof id === 'string' ? useTrainingQuery({ variables: { id } }) : {}
+
+  let content
+  if (loading) content = <p>Loading data...</p>
+  if (error) content = <p>Error loading data.</p>
+  else content = <EditTraining training={data.training} />
 
-const CreateTrainingPage = () => {
-  const newTraining = emptyTraining()
-  return (
-    <AdminPage>
-      <EditTraining training={newTraining} />
-    </AdminPage>
-  )
+  return <AdminPage>{content}</AdminPage>
 }
 
-export default CreateTrainingPage
+export default EditTrainingPage

+ 5 - 2
frontend/pages/admin/training/index.tsx

@@ -3,7 +3,6 @@ import {
   useDeleteTrainingMutation,
   TrainingsDocument,
   usePublishMutation,
-  PublishDocument,
 } from '../../../src/gql'
 import { FunctionComponent } from 'react'
 import { TTraining } from '../../../src/training/types'
@@ -29,8 +28,9 @@ const AdminTraining: FunctionComponent<{ item: TTraining; className?: string }>
           onClick={(event) =>
             publish({ variables: { training: item.id, status: !item.published } })
           }
+          className={item.published ? 'true' : 'false'}
         >
-          <FontAwesomeIcon icon={item.published ? faEye : faEyeSlash} height={16} />
+          <FontAwesomeIcon icon={faEye} height={16} />
         </button>
       </div>
       <div className='admin-training-title'>{item.title}</div>{' '}
@@ -58,6 +58,9 @@ const AdminTraining: FunctionComponent<{ item: TTraining; className?: string }>
           color: ${theme.colors.buttonBackground};
           background-color: transparent;
         }
+        button.false {
+          color: #5557;
+        }
       `}</style>
     </div>
   )

+ 97 - 0
frontend/pages/admin/user/index.tsx

@@ -0,0 +1,97 @@
+import {
+  useUsersQuery,
+  useUserDeleteMutation,
+  UsersDocument,
+  Permission,
+  useUpdatePermissionsMutation,
+} from '../../../src/gql'
+import { FunctionComponent } from 'react'
+import { TUser } from '../../../src/user/types'
+import { AdminList } from '../../../src/app'
+import theme from '../../../src/styles/theme'
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
+import { faDumbbell, faUserNinja } from '@fortawesome/free-solid-svg-icons'
+
+const AdminUser: FunctionComponent<{ item: TUser; className?: string }> = ({ item, className }) => {
+  const [updatePermission, updateDate] = useUpdatePermissionsMutation({
+    refetchQueries: [{ query: UsersDocument }],
+  })
+  const isAdmin = item.permissions?.includes(Permission.Admin)
+  const isInstructor = item.permissions?.includes(Permission.Instructor)
+  const { permissions = [] } = item
+
+  return (
+    <div className={className}>
+      <div className='admin-training-title'>{item.name}</div>
+      <div className='admin-training-email'>{item.email}</div>
+      <div className='admin-training-permissions'>
+        <button
+          onClick={(event) => {
+            const newPermissions = isInstructor
+              ? permissions.filter((permission) => Permission.Instructor !== permission)
+              : [...permissions, Permission.Instructor]
+            updatePermission({ variables: { id: item.id, permissions: newPermissions } })
+          }}
+          title='Instructor'
+          className={isInstructor ? 'true' : 'false'}
+        >
+          <FontAwesomeIcon icon={faDumbbell} height={16} />
+        </button>
+        <button
+          onClick={(event) => {
+            const newPermissions = isAdmin
+              ? permissions.filter((permission) => Permission.Admin !== permission)
+              : [...permissions, Permission.Admin]
+            updatePermission({ variables: { id: item.id, permissions: newPermissions } })
+          }}
+          title='Admin'
+          className={isAdmin ? 'true' : 'false'}
+        >
+          <FontAwesomeIcon icon={faUserNinja} height={16} />
+        </button>
+      </div>
+      <style jsx>{`
+        .${className} {
+          display: flex;
+        }
+        .admin-training-title {
+          width: 15%;
+        }
+        .admin-training-email {
+          width: 65%;
+        }
+        .admin-training-permissions {
+          width: 20%;
+          text-align: left;
+        }
+        button {
+          display: inline;
+          margin: 0 0.4em;
+          padding: 0;
+          color: ${theme.colors.buttonBackground};
+          background-color: transparent;
+        }
+        button.false {
+          color: #5557;
+        }
+      `}</style>
+    </div>
+  )
+}
+
+const AdminUsers = () => {
+  const props = {
+    name: 'Users',
+    adminMenu: '/admin/user',
+    get: useUsersQuery(),
+    remove: useUserDeleteMutation({
+      refetchQueries: [{ query: UsersDocument }],
+      update: (args) => console.log(args),
+    }),
+    dataKey: 'users',
+    Component: AdminUser,
+  }
+  return <AdminList {...props} />
+}
+
+export default AdminUsers

+ 0 - 13
frontend/pages/admin/users.tsx

@@ -1,13 +0,0 @@
-import { withRouter } from 'next/router'
-
-import { useCurrentUserQuery } from '../../src/gql'
-import { UserAdmin } from '../../src/user'
-
-const UserAdminPage = () => {
-  const { data, loading, error } = useCurrentUserQuery()
-  console.log('UserPage', data, loading, error && error.message)
-
-  return <UserAdmin />
-}
-
-export default withRouter(UserAdminPage)

+ 3 - 0
frontend/pages/admin/video/[id].tsx

@@ -0,0 +1,3 @@
+import EditVideoPage from './create'
+
+export default EditVideoPage

+ 23 - 0
frontend/pages/admin/video/create.tsx

@@ -0,0 +1,23 @@
+import { useRouter } from 'next/router'
+import { useVideoQuery } from '../../../src/gql'
+import EditVideo from '../../../src/file/components/EditVideo'
+import { AdminPage } from '../../../src/app'
+
+const EditVideoPage = () => {
+  const router = useRouter()
+  const { id } = router.query
+
+  const { data = undefined, error = undefined, loading = false } = id
+    ? useVideoQuery({
+        variables: { where: { id: typeof id === 'string' ? id : '' } },
+      })
+    : {}
+
+  let content
+  if (error) content = <p>Error loading video.</p>
+  else if (loading) content = <p>Loading video...</p>
+  else content = <EditVideo video={data?.video} />
+  return <AdminPage>{content}</AdminPage>
+}
+
+export default EditVideoPage

+ 55 - 0
frontend/pages/admin/video/index.tsx

@@ -0,0 +1,55 @@
+import { useVideosQuery, Video } from '../../../src/gql'
+import { FunctionComponent } from 'react'
+import { AdminList } from '../../../src/app'
+import theme from '../../../src/styles/theme'
+import FileSelector from '../../../src/file/components/FileSelector'
+
+const AdminVideo: FunctionComponent<{ item: Partial<Video>; className?: string }> = ({
+  item,
+  className,
+}) => {
+  return (
+    <div className={className}>
+      <div className='admin-video-title'>{item.title}</div>
+      <div className='admin-video-description'>{item.description}</div>
+      <div className='admin-video-file'>{item.file?.filename}</div>
+      <style jsx>{`
+        .${className} {
+          display: flex;
+        }
+        .admin-video-title {
+          width: 15%;
+        }
+        .admin-video-description {
+          width: 65%;
+        }
+        .admin-video-file {
+          width: 20%;
+          text-align: right;
+        }
+        button {
+          margin: 0 0.4em;
+          padding: 0;
+          color: ${theme.colors.buttonBackground};
+          background-color: transparent;
+        }
+        button.false {
+          color: #5557;
+        }
+      `}</style>
+    </div>
+  )
+}
+
+const AdminVideos = () => {
+  const props = {
+    name: 'Videos',
+    adminMenu: '/admin/video',
+    get: useVideosQuery(),
+    dataKey: 'videos',
+    Component: AdminVideo,
+  }
+  return <AdminList {...props} />
+}
+
+export default AdminVideos

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

@@ -26,6 +26,11 @@ const AdminSideBar = () => {
             <li className='admin-item'>Files</li>
           </a>
         </Link>
+        <Link href='/admin/video'>
+          <a>
+            <li className='admin-item'>Files</li>
+          </a>
+        </Link>
         <Link href='/admin/user'>
           <a>
             <li className='admin-item'>Users</li>

+ 1 - 1
frontend/src/app/components/Header.tsx

@@ -14,7 +14,7 @@ const Header = () => (
         text-align: center;
         background-color: #efefef;
         width: 100%;
-        z-index: 999;
+        z-index: 10;
         box-shadow: ${theme.bs};
       }
 

+ 35 - 44
frontend/src/dropdown/components/Dropdown.tsx

@@ -1,50 +1,43 @@
-import { useState, ReactChild, FunctionComponent } from 'react'
-import onClickOutside from 'react-onclickoutside'
+import { useState, FunctionComponent, useRef } from 'react'
+import { useOnClickOutside } from '../../lib/onClickOutside'
+import { customEvent } from '../../lib/customEvent'
 
-interface IDropdown {
-  items?: {
-    key: string | number
-    title: string | ReactChild
-    content: ReactChild
-  }[]
-  selectItem: (key: number | string) => void
+export interface IDropdown {
+  items?: IDropdownItem[]
+  name: string
+  value: string
+  onChange: (event: CustomChangeEvent) => void
 }
 
-const Dropdown: FunctionComponent<IDropdown> & {
-  handleClickOutside?: () => void
-} = ({ items, selectItem }: IDropdown) => {
+export interface IDropdownItem {
+  key: string | number
+  title: string
+}
+
+const DropdownItem: FunctionComponent<IDropdownItem> = () => {
+  return <li>nothing</li>
+}
+
+const Dropdown: FunctionComponent<IDropdown> = ({
+  items = [],
+  name,
+  value,
+  onChange,
+  children,
+}) => {
   const [open, setOpen] = useState(false)
-  const [active, setActive] = useState<undefined | string | number>()
-  Dropdown.handleClickOutside = () => setOpen(false)
+  const [active, setActive] = useState<undefined | IDropdownItem>()
+  const ref = useRef<HTMLDivElement>(null)
+  useOnClickOutside(ref, () => setOpen(false))
 
-  const activeItem = active !== undefined && items && items.find((item) => item.key === active)
+  const activeItem = active && items?.find((item) => item.key === active.key)
 
   return (
-    <div id='dd-container'>
-      <div id='dd-header'>
-        <p onClick={(event) => setOpen(!open)}>
-          {activeItem ? activeItem.title : 'please select one'}
-        </p>
+    <div id='dd-container' ref={ref}>
+      <div id='dd-header' onClick={() => setOpen(!open)}>
+        {activeItem ? activeItem.title : 'please select one'}
       </div>
-      <ul id='dd-items'>
-        {items &&
-          items.map((item) => (
-            <li
-              key={item.key}
-              onClick={() => {
-                setActive(item.key)
-                selectItem(item.key)
-                setOpen(false)
-              }}
-            >
-              {typeof item.content === 'string' ? (
-                <p className={active === item.key ? 'active' : undefined}>{item.content}</p>
-              ) : (
-                item.content
-              )}
-            </li>
-          ))}
-      </ul>
+      <ul id='dd-items'>{items}</ul>
       <div>{open ? 'open' : 'closed'}</div>
       <div>{active}</div>
 
@@ -56,7 +49,7 @@ const Dropdown: FunctionComponent<IDropdown> & {
         }
 
         #dd-header p {
-          background-color: #995555;
+          background-color: #99555533;
           margin: 0;
           padding: 0;
         }
@@ -70,13 +63,11 @@ const Dropdown: FunctionComponent<IDropdown> & {
           overflow-y: scroll;
           margin: 0;
           padding: 0;
-          background-color: #559955;
+          background-color: #55995533;
         }
       `}</style>
     </div>
   )
 }
 
-export default onClickOutside(Dropdown, {
-  handleClickOutside: () => Dropdown.handleClickOutside,
-})
+export default Dropdown

+ 61 - 0
frontend/src/file/components/EditVideo.tsx

@@ -0,0 +1,61 @@
+import { Video, useCreateVideoMutation, useUpdateVideoMutation } from '../../gql'
+import { TextInput, useForm } from '../../form'
+import FileSelector from './FileSelector'
+import { useRouter } from 'next/router'
+import { prepareDataForDB } from '../../training/utils'
+
+export interface IEditVideo {
+  video?: Partial<Video>
+}
+
+const EditVideo = ({ video }: IEditVideo) => {
+  const { values, onChange } = useForm(video ?? {})
+  const [createVideo, createData] = useCreateVideoMutation()
+  const [updateVideo, updateData] = useUpdateVideoMutation()
+  const router = useRouter()
+
+  return (
+    <form
+      onSubmit={async (event) => {
+        event.preventDefault()
+        const data = prepareDataForDB(values, video)
+        console.log(data, video)
+        if (video) {
+          const { id, ...others } = data
+          updateVideo({ variables: { data: others, where: { id: video.id } } })
+        } else {
+          const createResult = await createVideo({ variables: { data } })
+          if (createResult) {
+            router.push(`/admin/video/[id]`, `/admin/video/${createResult.data?.createVideo.id}`)
+          }
+        }
+      }}
+    >
+      <TextInput name='title' label='Title' value={values.title} onChange={onChange} />
+      <TextInput
+        name='description'
+        label='Description'
+        value={values.description}
+        onChange={onChange}
+      />
+      <TextInput
+        name='duration'
+        label='Duration'
+        value={values.duration}
+        type='number'
+        onChange={onChange}
+      />
+      <FileSelector
+        name='file'
+        value={values.file}
+        onChange={onChange}
+        queryConf={{ where: { mimetype_starts_with: 'video/' } }}
+      />
+      {values.file?.thumbnail && <img src={`/${values.file.thumbnail}`} alt={values.title} />}
+      <TextInput name='link' label='Link' value={values.link} onChange={onChange} />
+      <button type='submit'>Save video</button>
+    </form>
+  )
+}
+
+export default EditVideo

+ 58 - 0
frontend/src/file/components/FileManager.tsx

@@ -0,0 +1,58 @@
+import { useState, ChangeEvent } from 'react'
+import { useFsFilesQuery, useFilesQuery } from '../../gql'
+import { danglingFsFiles } from '../utils'
+
+const FileManager = () => {
+  const [folder, setFolder] = useState('upload_files')
+
+  const uploadFiles = useFsFilesQuery({ variables: { directory: folder } })
+  const files = useFilesQuery()
+
+  const error = (uploadFiles.error || files.error) && <p>Fehler beim Laden der Dateien...</p>
+  const loading = (uploadFiles.loading || files.loading) && <p>Lade Dateien...</p>
+
+  const danglingFsFilesFound = danglingFsFiles(uploadFiles.data, files.data)
+
+  function handleChange(event: ChangeEvent<HTMLInputElement>) {
+    setFolder(event.target.value)
+  }
+
+  return (
+    <form>
+      <h1>File Manager</h1>
+      <input type='text' name='folder' id='folder' value={folder} onChange={handleChange} />
+      {error}
+      {loading}
+      {danglingFsFilesFound ? (
+        <table>
+          <thead>
+            <tr>
+              <th></th>
+              <th>Datei</th>
+            </tr>
+          </thead>
+          <tbody>
+            {files.data?.files.map((file, index) =>
+              file ? (
+                <tr key={index}>
+                  <td>
+                    <input type='checkbox' />
+                  </td>
+                  <td>
+                    <a href={`/${file.path}`} download={file.filename}>
+                      {file.filename}
+                    </a>
+                  </td>
+                </tr>
+              ) : null
+            )}
+          </tbody>
+        </table>
+      ) : (
+        <p>no dangeling files found.</p>
+      )}
+    </form>
+  )
+}
+
+export default FileManager

+ 63 - 0
frontend/src/file/components/FileSelector.tsx

@@ -0,0 +1,63 @@
+import { useFilesQuery, File, FilesQueryVariables } from '../../gql'
+import { ChangeEvent, useState } from 'react'
+import { customEvent } from '../../lib/customEvent'
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
+import { faUpload } from '@fortawesome/free-solid-svg-icons'
+import { Modal } from '../../modal'
+import { UploadFile } from '../index'
+
+const FileSelector = ({
+  name,
+  value,
+  onChange,
+  queryConf,
+}: {
+  name: string
+  value?: File
+  queryConf?: FilesQueryVariables
+  onChange: (event: CustomChangeEvent) => void
+}) => {
+  const { data, error, loading, refetch } = useFilesQuery({ variables: queryConf })
+  const [modal, setModal] = useState(false)
+
+  if (error) return <p>Error loading files.</p>
+  if (loading) return <p>Loading files.</p>
+  if (!data) return <p>No data found.</p>
+
+  function handleChange(event: ChangeEvent<HTMLSelectElement>) {
+    if (data?.files)
+      onChange(
+        customEvent(
+          name,
+          data.files.find((file) => file.id === event.target.value)
+        )
+      )
+  }
+
+  return (
+    <>
+      <select name={name} value={value?.id} onChange={handleChange}>
+        <option>Please select a file.</option>
+        {data.files.map((file) => (
+          <option key={file.id} value={file.id}>
+            {file.filename}
+          </option>
+        ))}
+      </select>
+      <button type='button' onClick={() => setModal(true)}>
+        <FontAwesomeIcon icon={faUpload} height={14} />
+      </button>
+      <Modal state={[modal, setModal]}>
+        <UploadFile
+          callback={(file) => {
+            refetch()
+            setModal(false)
+            onChange(customEvent('file', file))
+          }}
+        />
+      </Modal>
+    </>
+  )
+}
+
+export default FileSelector

+ 24 - 90
frontend/src/file/components/UploadFile.tsx

@@ -1,111 +1,45 @@
-import { useState, ChangeEvent } from 'react'
+import { useUploadFileMutation, File } from '../../gql'
+import { useForm, TextInput } from '../../form'
+import { customEvent } from '../../lib/customEvent'
 
-import { useUploadFileMutation, useFsFilesQuery, useFilesQuery } from '../../gql'
-import { danglingFsFiles } from '../utils'
-
-interface IForm {
-  file: File | null
-  comment: string
-}
-
-const initialVariables: IForm = {
-  file: null,
-  comment: '',
-}
-
-const FileManager = () => {
-  const [folder, setFolder] = useState('upload_files')
-
-  const uploadFiles = useFsFilesQuery({ variables: { directory: folder } })
-  const files = useFilesQuery()
-
-  const error = (uploadFiles.error || files.error) && <p>Fehler beim Laden der Dateien...</p>
-  const loading = (uploadFiles.loading || files.loading) && <p>Lade Dateien...</p>
-
-  const danglingFsFilesFound = danglingFsFiles(uploadFiles.data, files.data)
-
-  function handleChange(event: ChangeEvent<HTMLInputElement>) {
-    setFolder(event.target.value)
-  }
-
-  return (
-    <form>
-      <h1>File Manager</h1>
-      <input type='text' name='folder' id='folder' value={folder} onChange={handleChange} />
-      {error}
-      {loading}
-      {danglingFsFilesFound ? (
-        <table>
-          <thead>
-            <tr>
-              <th></th>
-              <th>Datei</th>
-            </tr>
-          </thead>
-          <tbody>
-            {files.data?.files.map((file, index) =>
-              file ? (
-                <tr key={index}>
-                  <td>
-                    <input type='checkbox' />
-                  </td>
-                  <td>
-                    <a href={`/${file.path}`} download={file.filename}>
-                      {file.filename}
-                    </a>
-                  </td>
-                </tr>
-              ) : null
-            )}
-          </tbody>
-        </table>
-      ) : (
-        <p>no dangeling files found.</p>
-      )}
-    </form>
-  )
-}
-
-const UploadFile = () => {
+const UploadFile = ({ callback }: { callback?: (file: File) => void }) => {
   const [uploadFile, { error, loading }] = useUploadFileMutation()
-  const [variables, setVariables] = useState(initialVariables)
+  const { values, onChange } = useForm({ file: undefined, comment: '' })
 
   return (
     <>
       <form
-        onSubmit={(event) => {
+        onSubmit={async (event) => {
           event.preventDefault()
-          uploadFile({ variables })
+          const file = await uploadFile({ variables: values })
+          callback && callback(file.data.uploadFile)
         }}
       >
+        <h2>File Upload</h2>
         <input
-          type='file'
           name='file'
-          id='file'
-          onChange={(event) =>
-            setVariables({
-              ...variables,
-              file: event.target.files && event.target.files[0],
-            })
-          }
+          type='file'
+          accept='video/*'
+          onChange={(event) => {
+            console.log(
+              event.target.files,
+              event.target.files?.item(0),
+              event.target.files?.item(0)?.type
+            )
+            onChange(customEvent('file', event.target.files?.item(0)))
+          }}
         />
-        <textarea
+        <TextInput
           name='comment'
-          id='comment'
-          placeholder='Kommentar'
-          onChange={(event) =>
-            setVariables({
-              ...variables,
-              comment: event.target.value,
-            })
-          }
-        ></textarea>
+          value={values.comment}
+          placeholder='Comment'
+          onChange={onChange}
+        />
         <button type='submit' disabled={loading}>
           Upload
         </button>
         {error && <div className='error'>Error</div>}
       </form>
-      <FileManager />
     </>
   )
 }

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

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

+ 120 - 3
frontend/src/file/file.graphql

@@ -10,10 +10,33 @@ query fsFiles($directory: String!) {
   }
 }
 
-query files {
-  files {
-    filename
+query files(
+  $where: FileWhereInput
+  $orderBy: FileOrderByInput
+  $skip: Int
+  $after: String
+  $before: String
+  $first: Int
+  $last: Int
+) {
+  files(
+    where: $where
+    orderBy: $orderBy
+    skip: $skip
+    after: $after
+    before: $before
+    first: $first
+    last: $last
+  ) {
+    id
     path
+    mimetype
+    user {
+      id
+      name
+    }
+    thumbnail
+    filename
     size
     updatedAt
     comment
@@ -23,5 +46,99 @@ query files {
 mutation uploadFile($file: Upload!, $comment: String) {
   uploadFile(file: $file, comment: $comment) {
     id
+    path
+    mimetype
+    thumbnail
+    filename
+    size
+    comment
+  }
+}
+
+query videos {
+  videos {
+    id
+    title
+    description
+    duration
+    file {
+      id
+      path
+      size
+      thumbnail
+      comment
+    }
+    link
+  }
+}
+
+query video($where: VideoWhereUniqueInput!) {
+  video(where: $where) {
+    id
+    title
+    description
+    duration
+    file {
+      id
+      path
+      size
+      thumbnail
+      comment
+    }
+  }
+}
+
+mutation createVideo($data: VideoCreateInput) {
+  createVideo(data: $data) {
+    id
+  }
+}
+
+mutation updateVideo($data: VideoUpdateInput!, $where: VideoWhereUniqueInput!) {
+  updateVideo(data: $data, where: $where) {
+    id
+  }
+}
+
+query pictures {
+  pictures {
+    id
+    title
+    description
+    file {
+      id
+      path
+      size
+      thumbnail
+      comment
+    }
+    link
+  }
+}
+
+query picture($where: VideoWhereUniqueInput!) {
+  picture(where: $where) {
+    id
+    title
+    description
+    file {
+      id
+      path
+      size
+      thumbnail
+      comment
+    }
+  }
+}
+
+mutation createPicture($data: PictureCreateInput) {
+  createPicture(data: $data) {
+    id
+  }
+}
+
+mutation updatePicture($data: PictureUpdateInput!, $where: PictureWhereUniqueInput!) {
+  updatePicture(data: $data, where: $where) {
+    id
   }
 }

+ 18 - 3
frontend/src/file/utils.ts

@@ -1,9 +1,24 @@
 import { FsFilesQuery, FilesQuery } from '../gql'
 
-export function danglingFsFiles(fsFiles: FsFilesQuery | undefined, dbFiles: FilesQuery | undefined) {
-  return fsFiles && fsFiles.fsFiles.filter(fsFile => dbFiles && !dbFiles.files.map(dbFile => dbFile && dbFile.path).includes(fsFile && fsFile.path))
+export function danglingFsFiles(
+  fsFiles: FsFilesQuery | undefined,
+  dbFiles: FilesQuery | undefined
+) {
+  return (
+    fsFiles &&
+    fsFiles.fsFiles.filter(
+      (fsFile) =>
+        dbFiles &&
+        !dbFiles.files.map((dbFile) => dbFile && dbFile.path).includes(fsFile && fsFile.path)
+    )
+  )
 }
 
 // export function danglingDbFiles(fsFiles: FsFile[], dbFiles: File[]) {
 //   return fsFiles.filter(fsFile => !dbFiles.map(dbFile => dbFile.path).includes(fsFile.path))
-// }
+// }
+export const fileFilters = {
+  audio: { db: { where: { mimetye_startsWith: '' } }, input: '' },
+  video: { db: { where: { mimetye_startsWith: 'video/' } }, input: 'video/*' },
+  picture: { db: { where: { mimetye_startsWith: 'image/' } }, input: 'image/*' },
+}

ファイルの差分が大きいため隠しています
+ 536 - 445
frontend/src/gql/index.tsx


+ 3 - 0
frontend/src/lib/customEvent.ts

@@ -0,0 +1,3 @@
+export function customEvent(name: string, value: any): CustomChangeEvent {
+  return { target: { type: 'custom', name, value } }
+}

+ 1 - 0
frontend/src/modal/styles/index.ts

@@ -8,6 +8,7 @@ export const modal = css`
     height: 100vh;
     width: 100vw;
     background-color: #000000bb;
+    z-index: 100;
   }
   .modal > .container {
     margin: 2em auto;

+ 0 - 8
frontend/src/styles/global.ts

@@ -110,14 +110,6 @@ const GlobalStyle = css.global`
     border: none;
     margin: none;
     padding: 1em 0.3em;
-    background: ${theme.colors.background};
-    box-shadow: ${theme.bsSmall};
-  }
-
-  form fieldset legend {
-    font-size: 120%;
-    padding: 0.5em;
-    box-shadow: ${theme.bsSmall};
   }
 
   @media (min-width: ${theme.midsize}) {

+ 81 - 39
frontend/src/training/__tests__/utils.test.ts

@@ -7,14 +7,14 @@ const typesFalsy = {
   array: [],
   object: {},
   undefined: undefined,
-  null: null
+  null: null,
 }
 const expectedFalsy = {
   number: 0,
   boolean: false,
   string: '',
   array: [],
-  null: null
+  null: null,
 }
 const typesTruthy = {
   number: 12,
@@ -23,21 +23,25 @@ const typesTruthy = {
   array: [1, 2, 3],
   object: { a: 1, b: 2 },
   undefined: 'not undefined',
-  null: 45
+  null: 45,
 }
 const dbId = 'dbId'
-const dbChildId = 'dbChildId'
 const createId = '++fakeId'
 const deleteId = '--dbId'
+const newContent = {
+  id: createId,
+  some: 'new content',
+  an: ['array', 'of', 'new', 'items'],
+}
 const originalContent = {
   id: 'dbChildId',
   some: 'original content',
-  an: ['array', 'of', 'strings']
+  an: ['array', 'of', 'strings'],
 }
 const changedContent = {
   id: 'dbChildId',
   some: 'changed content',
-  an: ['array', 'of', 'more', 'strings']
+  an: ['array', 'of', 'more', 'strings'],
 }
 
 const dbItem = {
@@ -45,16 +49,7 @@ const dbItem = {
   unchangedChild: originalContent,
   updatedChild: originalContent,
   deletedChild: originalContent,
-  nestedChild: { ...originalContent, child: originalContent }
-}
-
-const formItem = {
-  id: dbId,
-  unchangedChild: originalContent,
-  createdChild: { ...changedContent, id: createId },
-  updatedChild: changedContent,
-  deletedChild: { ...changedContent, id: deleteId },
-  nestedChild: { ...originalContent, child: changedContent }
+  nestedChild: { ...originalContent, child: originalContent },
 }
 
 describe('diffDB: Find differences of current state to database', () => {
@@ -70,17 +65,40 @@ describe('diffDB: Find differences of current state to database', () => {
     const falsyDiff = diffDB(typesFalsy, typesTruthy)
     expect(falsyDiff).toEqual(expectedFalsy)
   })
+
+  const updateItem = {
+    id: dbId,
+    unchangedChild: originalContent,
+    createdChild: newContent,
+    updatedChild: changedContent,
+    deletedChild: { ...changedContent, id: deleteId },
+    nestedChild: { ...originalContent, child: changedContent },
+  }
   it('diffs nested items correctly', () => {
-    const a = diffDB(formItem, dbItem)
-    expect(a).toEqual({
+    expect(diffDB(updateItem, dbItem)).toEqual({
       id: '@@dbId',
-      createdChild: { ...changedContent, id: createId },
+      createdChild: newContent,
       updatedChild: { ...changedContent, id: '@@dbChildId' },
       deletedChild: { id: deleteId },
       nestedChild: {
         id: '@@dbChildId',
-        child: { ...changedContent, id: '@@dbChildId' }
-      }
+        child: { ...changedContent, id: '@@dbChildId' },
+      },
+    })
+  })
+
+  const createItem = {
+    id: createId,
+    createdChild: newContent,
+    newNestedChild: { ...newContent, child: newContent },
+    originalNestedChild: { ...newContent, child: originalContent },
+  }
+  it('diffs nested items correctly', () => {
+    expect(diffDB(createItem, dbItem)).toEqual({
+      id: createId,
+      createdChild: newContent,
+      newNestedChild: { ...newContent, child: newContent },
+      originalNestedChild: { ...newContent, child: originalContent },
     })
   })
 })
@@ -90,7 +108,7 @@ const collection = [
   { update: changedContent },
   { delete: changedContent },
   { update: originalContent },
-  { connect: originalContent }
+  { connect: originalContent },
 ]
 const stringArray = ['do', 'not', 'touch']
 describe('collects DB changes.', () => {
@@ -99,37 +117,61 @@ describe('collects DB changes.', () => {
       create: [originalContent],
       update: [changedContent, originalContent],
       delete: [changedContent],
-      connect: [originalContent]
+      connect: [originalContent],
     })
   })
-  it('leaves other arrays untouched.', () => {
+  it('creates sets of string arrays.', () => {
     expect(collectMutations(stringArray)).toEqual({ set: stringArray })
   })
 })
 
-const formData = {
-  id: '@@toUpdate',
-  value: '14',
-  child: { id: '--toDelete' },
-  children: [
-    { id: '++toCreate', value: 'nice' },
-    { id: '@@toUpdate', value: 'good' },
-    { id: 'toConnect', value: 'fine' }
-  ],
-  arrays: ['do', 'not', 'touch', 'strings']
-}
 describe('transforms form data to DB data', () => {
-  it('does it.', () => {
-    expect(transform(formData, transformArrayToDB)).toEqual({
+  const updateData = {
+    id: '@@toUpdate',
+    value: '14',
+    child: { id: '--toDelete' },
+    children: [
+      { id: '++toCreate', value: 'nice' },
+      { id: '@@toUpdate', value: 'good' },
+      { id: 'toConnect', value: 'fine' },
+    ],
+    arrays: ['do', 'not', 'touch', 'strings'],
+  }
+  it('updates records.', () => {
+    expect(transform(updateData, transformArrayToDB)).toEqual({
       id: '@@toUpdate',
       value: '14',
       child: { delete: { id: 'toDelete' } },
       children: {
         create: [{ value: 'nice' }],
         update: [{ where: { id: 'toUpdate' }, data: { value: 'good' } }],
-        connect: [{ id: 'toConnect' }]
+        connect: [{ id: 'toConnect' }],
+      },
+      arrays: { set: ['do', 'not', 'touch', 'strings'] },
+    })
+  })
+
+  const createData = {
+    id: '++toCreate',
+    value: '14',
+    child: { id: '--toDelete' },
+    children: [
+      { id: '++toCreate', value: 'nice' },
+      { id: '@@toUpdate', value: 'good' },
+      { id: 'toConnect', value: 'fine' },
+    ],
+    arrays: ['do', 'not', 'touch', 'strings'],
+  }
+  it('creates records.', () => {
+    expect(transform(createData, transformArrayToDB)).toEqual({
+      value: '14',
+      child: { delete: { id: 'toDelete' } },
+      children: {
+        create: [{ value: 'nice' }],
+        update: [{ where: { id: 'toUpdate' }, data: { value: 'good' } }],
+        connect: [{ id: 'toConnect' }],
       },
-      arrays: { set: ['do', 'not', 'touch', 'strings'] }
+      arrays: { set: ['do', 'not', 'touch', 'strings'] },
     })
   })
 })

+ 11 - 28
frontend/src/training/components/BlockInputs.tsx

@@ -1,35 +1,31 @@
 import FormatSelector from './FormatSelector'
 import { TextInput } from '../../form'
-import BlockInstanceInputs from './BlockInstanceInputs'
-import { emptyBlockInstance, emptyExercise, emptyExerciseInstance } from '../utils'
+import { emptyBlockInstance, emptyExerciseInstance } from '../utils'
 import ExerciseInstanceInputs from './ExerciseInstanceInputs'
 import { TBlock } from '../types'
-import { useState, ChangeEvent } from 'react'
 import BlockSelector from './BlockSelector'
 import BlockList from './BlockList'
+import VideoSelector from '../../file/components/VideoSelector'
 
 interface IBlockInputs {
   onChange: GenericEventHandler
-  value: TBlock
+  value?: TBlock
   name: string
   className?: string
 }
 
-const BlockInputs = ({ onChange, value, name, className }: IBlockInputs) => {
-  const [state, setState] = useState({ new: {}, old: {} })
+const BlockInputs = (props?: IBlockInputs) => {
+  if (!props || !props.value) return null
+
+  const { onChange, value, name, className } = props
   return (
-    <div className={className}>
+    <fieldset className={className}>
       {!value.id.startsWith('++') && (
         <div className='block-info'>
           <div>{value.id}</div>
-          <div>{/*value.createdAt*/}</div>
+          <div>value.createdAt</div>
         </div>
       )}
-      <label>New block</label>
-      <input type='radio' name='source' value='new' />
-      <label>Use existing block</label>
-      <input type='radio' name='source' value='existing' />
-      <BlockSelector name={name} value={value} label='Existing block' onChange={onChange} />
       <TextInput name={`${name}.title`} label='Title' value={value.title} onChange={onChange} />
       <TextInput
         name={`${name}.description`}
@@ -52,20 +48,7 @@ const BlockInputs = ({ onChange, value, name, className }: IBlockInputs) => {
         type='number'
         onChange={onChange}
       />
-      <TextInput
-        name={`${name}.videos`}
-        label='Video'
-        value={value.videos && value.videos.length > 0 ? value.videos[0] : ''}
-        onChange={(event) =>
-          onChange({
-            target: {
-              type: 'custom',
-              name: `${name}.videos`,
-              value: [event.target.value],
-            },
-          })
-        }
-      />
+      <VideoSelector name={`${name}.videos`} value={value.videos} onChange={onChange} />
       <label>Blocks</label>
       <BlockList
         name={`${name}.blocks`}
@@ -124,7 +107,7 @@ const BlockInputs = ({ onChange, value, name, className }: IBlockInputs) => {
           justify-content: space-between;
         }
       `}</style>
-    </div>
+    </fieldset>
   )
 }
 

+ 92 - 20
frontend/src/training/components/BlockInstanceInputs.tsx

@@ -1,21 +1,46 @@
 import BlockInputs from './BlockInputs'
 import { TextInput } from '../../form'
-import { TBlockInstance } from '../types'
+import { TBlockInstance, TBlock } from '../types'
 import theme from '../../styles/theme'
+import { useState, useEffect, ChangeEvent } from 'react'
+import { emptyBlock, countBlocksAndExercises } from '../utils'
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
+import { faChevronRight, faChevronDown } from '@fortawesome/free-solid-svg-icons'
+import BlockSelector from './BlockSelector'
 
 const BlockInstanceInputs = ({
   value,
   name,
   onChange,
-  className,
+  className = 'bi-fields',
 }: {
   value: TBlockInstance
   name: string
   onChange: GenericEventHandler
   className?: string
 }) => {
+  const [show, setShow] = useState(true)
+  const [source, setSource] = useState<'new' | 'existing'>()
+  const [newItem, setNewItem] = useState(emptyBlock())
+  const [existingItem, setExistingItem] = useState<undefined | TBlock>()
+
+  function handleSourceChange(event: ChangeEvent<HTMLInputElement>) {
+    const { target } = event
+    if (target.value === 'new') {
+      if (!value.block?.id.startsWith('++')) setExistingItem(value.block)
+      setSource('new')
+      onChange({ target: { type: 'custom', name: `${name}.block`, value: newItem } })
+    } else if (target.value === 'existing') {
+      if (value.block?.id.startsWith('++')) setNewItem(value.block)
+      setSource('existing')
+      onChange({ target: { type: 'custom', name: `${name}.block`, value: existingItem } })
+    }
+  }
+
+  const [blocks, exercises] = countBlocksAndExercises(value.block)
+
   return (
-    <div className={className}>
+    <fieldset className={className}>
       {!value.id.startsWith('++') && (
         <div className='bi-info'>
           <div>Block Instance ID{value.id}</div>
@@ -45,42 +70,89 @@ const BlockInstanceInputs = ({
         onChange={onChange}
         className='bi-variation'
       />
-      <BlockInputs
-        name={`${name}.block`}
-        value={value.block}
-        onChange={onChange}
-        className='bi-block'
-      />
+      <div className='bi-block'>
+        <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 block</label>
+        <input
+          type='radio'
+          name='source'
+          id='source-existing'
+          value='existing'
+          checked={source === 'existing'}
+          onChange={handleSourceChange}
+        />
+        <label htmlFor='source-existing'>Existing block</label>
+        {value.block ? (
+          <div>
+            Block: {value.block.title ?? value.block.description?.substr(0, 50)}, sub blocks:{' '}
+            {blocks}, exercises: {exercises}
+          </div>
+        ) : (
+          <div>No block selected</div>
+        )}
+
+        {show &&
+          (source === 'new' ? (
+            <BlockInputs name={`${name}.block`} value={value.block} onChange={onChange} />
+          ) : source === 'existing' ? (
+            <BlockSelector
+              name={`${name}.block`}
+              value={value.block}
+              label='Existing block'
+              onChange={onChange}
+            />
+          ) : (
+            <p>Please select a block</p>
+          ))}
+      </div>
 
       <style jsx>{`
+        .bi-block > label,
+        .bi-block > input[type='radio'] {
+          display: inline;
+          width: auto;
+        }
         @media (min-width: ${theme.midsize}) {
-          div {
+          .${className} {
             display: grid;
             grid-template-areas:
-              'order  rounds variation'
-              'block  block  block'
-              'button button button';
-            grid-template-columns: 1fr 1fr 2fr;
+              'info info info info'
+              'order  rounds variation variation'
+              'block  block  block block'
+              'button button button button';
+            grid-template-columns: repeat(4, 1fr);
           }
 
-          div :global(.bi-order) {
+          .${className} :global(.bi-order) {
             grid-area: order;
           }
-          div :global(.bi-rounds) {
+          .${className} :global(.bi-rounds) {
             grid-area: rounds;
           }
-          div :global(.bi-variation) {
+          .${className} :global(.bi-variation) {
             grid-area: variation;
           }
-          div :global(.bi-block) {
+          .${className} :global(.bi-block) {
             grid-area: block;
           }
-          div :global(.bi-button) {
+          .${className} :global(.bi-button) {
             grid-area: button;
           }
         }
       `}</style>
-    </div>
+    </fieldset>
   )
 }
 

+ 25 - 43
frontend/src/training/components/BlockSelector.tsx

@@ -1,5 +1,5 @@
 import { useBlocksQuery } from '../../gql'
-import { useState, useEffect } from 'react'
+import { ChangeEvent } from 'react'
 import { TBlock } from '../types'
 
 interface IBlockSelector {
@@ -9,57 +9,39 @@ interface IBlockSelector {
   label?: string
 }
 
-const BlockSelector = ({
-  value,
-  onChange,
-  name = 'block',
-  label = 'Block'
-}: IBlockSelector) => {
-  const [state, setState] = useState(value?.id ?? '')
+const BlockSelector = ({ value, onChange, name = 'block', label = 'Block' }: IBlockSelector) => {
   const blocks = useBlocksQuery()
 
-  useEffect(() => {
-    setState(value?.id || '')
-  }, [value])
+  function handleChange(event: ChangeEvent<HTMLSelectElement>) {
+    const { name, value } = event.target
+    onChange({
+      target: {
+        type: 'custom',
+        name,
+        value: blocks.data?.blocks.find((block) => block.id === value),
+      },
+    })
+  }
 
   return (
     <>
       <label>{label}</label>
-      <select
-        id={name}
-        name={name}
-        value={state}
-        onChange={ev => setState(ev.target.value)}
-      >
+      <select id={name} name={name} value={value?.id} onChange={handleChange}>
         {blocks.loading && 'loading blocks...'}
         {blocks.error && 'error loading blocks'}
-        {blocks.data &&
-          blocks.data.blocks.map(block => (
-            <option key={block.id} value={block.id}>
-              {[block.title, block.description?.slice(0, 60)]
-                .filter(block => !!block)
-                .join(' - ')}
-            </option>
-          ))}
+        {blocks.data && (
+          <>
+            <option key='not-selected'>Please select a block.</option>
+            {blocks.data.blocks.map((block) => (
+              <option key={block.id} value={block.id}>
+                {[block.title, block.description?.slice(0, 60)]
+                  .filter((block) => !!block)
+                  .join(' - ')}
+              </option>
+            ))}
+          </>
+        )}
       </select>
-      <button
-        type='button'
-        onClick={event => {
-          const block =
-            blocks.data && blocks.data.blocks.find(block => block.id === state)
-          if (!block) return
-          const changeEvent: CustomChangeEvent = {
-            target: {
-              type: 'custom',
-              value: block,
-              name
-            }
-          }
-          onChange(changeEvent)
-        }}
-      >
-        Use
-      </button>
     </>
   )
 }

+ 4 - 13
frontend/src/training/components/EditBlock.tsx

@@ -1,27 +1,18 @@
-import { BlockContentFragment } from '../../gql'
 import FormatSelector from './FormatSelector'
 import { TextInput } from '../../form'
+import { TBlock } from '../types'
 
 interface IBlockFormInputs {
   onChange: GenericEventHandler
-  value: BlockContentFragment
+  value: TBlock
   name: string
 }
 
 const BlockFormInputs = ({ onChange, value, name }: IBlockFormInputs) => {
   return (
     <fieldset>
-      <TextInput
-        name={`${name}.title`}
-        label='Title'
-        value={value.title}
-        onChange={onChange}
-      />
-      <FormatSelector
-        name={`${name}.format`}
-        value={value.format}
-        onChange={onChange}
-      />
+      <TextInput name={`${name}.title`} label='Title' value={value.title} onChange={onChange} />
+      <FormatSelector name={`${name}.format`} value={value.format} onChange={onChange} />
       <TextInput
         name={`${name}.description`}
         label='Description'

+ 31 - 26
frontend/src/training/components/EditTraining.tsx

@@ -11,7 +11,7 @@ import theme from '../../styles/theme'
 const EditTraining = ({ training }: { training?: TTraining }) => {
   const { values, touched, onChange, loadData } = useForm(training || emptyTraining())
   const [createTraining, createData] = useCreateTrainingMutation()
-  const [updateTraining, updateDate] = useUpdateTrainingMutation()
+  const [updateTraining, updateData] = useUpdateTrainingMutation()
 
   return (
     <form
@@ -24,14 +24,14 @@ const EditTraining = ({ training }: { training?: TTraining }) => {
         }
         console.log(newValues)
         const { id, ...data } = newValues
-        if (id.startsWith('++')) {
-          const createData = await createTraining({ variables: data })
-          console.log('created training', createData)
-        } else if (id.startsWith('@@')) {
+        if (id?.startsWith('@@')) {
           const updateData = await updateTraining({
             variables: { where: { id: id.substr(2) }, data },
           })
           console.log('updated training', updateData)
+        } else if (id.startsWith('@@')) {
+          const createData = await createTraining({ variables: data })
+          console.log('created training', createData)
         }
       }}
     >
@@ -55,6 +55,13 @@ const EditTraining = ({ training }: { training?: TTraining }) => {
           onChange={onChange}
           className='training-type'
         />
+        <Checkbox
+          name='published'
+          label='Published'
+          value={values.published}
+          onChange={onChange}
+          className='training-published'
+        />
         <DateTimeInput
           name='trainingDate'
           label='Training date'
@@ -69,7 +76,6 @@ const EditTraining = ({ training }: { training?: TTraining }) => {
           onChange={onChange}
           className='training-location'
         />
-        <Registrations registrations={values.registrations} className='training-registrations' />
         <TextInput
           name='attendance'
           label='Attendance'
@@ -78,25 +84,25 @@ const EditTraining = ({ training }: { training?: TTraining }) => {
           onChange={onChange}
           className='training-attendance'
         />
+        <Registrations registrations={values.registrations} className='training-registrations' />
         <Ratings ratings={values.ratings} className='training-ratings' />
-        <Checkbox
-          name='published'
-          label='Published'
-          value={values.published}
-          onChange={onChange}
-          className='training-published'
-        />
         <BlockList
           name='blocks'
           value={values.blocks}
           onChange={onChange}
           className='training-blocks'
         />
-        <button type='submit' disabled={createData.loading} className='training-button'>
-          Save Training
-        </button>
-        {createData.data && <span color='green'>Saved.</span>}
-        {createData.error && <span color='red'>Error saving: {createData.error.message}</span>}
+        <div className='training-button'>
+          <button type='submit' disabled={createData.loading}>
+            Save Training
+          </button>
+          {(createData.data || updateData.data) && <span color='green'>Saved.</span>}
+          {(createData.error || updateData.error) && (
+            <span color='red'>
+              Error saving: {createData.error?.message ?? updateData.error?.message}
+            </span>
+          )}
+        </div>
       </fieldset>
 
       <style jsx>{`
@@ -109,14 +115,13 @@ const EditTraining = ({ training }: { training?: TTraining }) => {
             display: grid;
             grid-gap: 0.2em 2em;
             grid-template-areas:
-              'info info info'
-              'title title type'
-              'time time location'
-              'published attendance empty2'
-              'registrations ratings empty3'
-              'blocks blocks blocks'
-              'button button button';
-            grid-template-columns: 1fr 1fr 1fr;
+              'info info info info'
+              'title title type published'
+              'time location location attendance'
+              'registrations registrations ratings ratings'
+              'blocks blocks blocks blocks'
+              'button button button button';
+            grid-template-columns: 1fr 1fr 1fr 1fr;
             align-items: center;
           }
           .fields-training :global(.training-info) {

+ 23 - 12
frontend/src/training/components/Ratings.tsx

@@ -1,20 +1,31 @@
 import { Rating } from '../../gql'
+import { useState } from 'react'
+
+const RatingItem = ({ rating }: { rating: Rating }) => {
+  return (
+    <li>
+      {rating.comment} {rating.value} {rating.user.name}
+    </li>
+  )
+}
 
 const Ratings = ({ ratings, className }: { ratings?: Rating[]; className?: string }) => {
+  const [show, setShow] = useState(false)
   return (
     <div className={className}>
-      <h2>Ratings</h2>
-      {ratings ? (
-        <ul>
-          {ratings.map((rating) => (
-            <li key={rating.id}>
-              {rating.comment} {rating.value} {rating.user.name}
-            </li>
-          ))}
-        </ul>
-      ) : (
-        <p>No ratings found</p>
-      )}
+      <h2>
+        Ratings <span onClick={() => setShow(!show)}>({ratings?.length ?? 0})</span>
+      </h2>
+      {show &&
+        (ratings ? (
+          <ul>
+            {ratings.map((rating) => (
+              <RatingItem key={rating.id} rating={rating} />
+            ))}
+          </ul>
+        ) : (
+          <p>No ratings found</p>
+        ))}
     </div>
   )
 }

+ 26 - 16
frontend/src/training/components/Registrations.tsx

@@ -1,4 +1,16 @@
 import { User } from '../../gql'
+import { useState } from 'react'
+
+const Registration = ({ registration }: { registration: User }) => {
+  return (
+    <li key={registration.id}>
+      <button type='button' onClick={() => alert('not implemented.')}>
+        delete
+      </button>
+      Registration: {registration.name}
+    </li>
+  )
+}
 
 const Registrations = ({
   registrations,
@@ -7,24 +19,22 @@ const Registrations = ({
   registrations?: User[]
   className: string
 }) => {
-  console.log(registrations)
+  const [show, setShow] = useState(false)
   return (
     <div className={className}>
-      <h2>Registrations</h2>
-      {registrations && registrations.length > 0 ? (
-        <ul>
-          {registrations.map((registration) => (
-            <li key={registration.id}>
-              <button type='button' onClick={() => alert('not implemented.')}>
-                delete
-              </button>
-              Registration: {registration.name}
-            </li>
-          ))}
-        </ul>
-      ) : (
-        <p>No registrations found.</p>
-      )}
+      <h2>
+        Registrations <span onClick={() => setShow(!show)}>({registrations?.length ?? 0})</span>
+      </h2>
+      {show &&
+        (registrations?.length ? (
+          <ul>
+            {registrations.map((registration) => (
+              <Registration registration={registration} />
+            ))}
+          </ul>
+        ) : (
+          <p>No registrations found.</p>
+        ))}
     </div>
   )
 }

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

@@ -2,7 +2,9 @@ import ExerciseComposition from './ExerciseComposition'
 import { calculateDuration, formatTime } from '../utils'
 import { TBlockInstance } from '../types'
 
-const TrainingBlock = ({ blockInstance }: { blockInstance: TBlockInstance }) => {
+const TrainingBlock = ({ blockInstance }: { blockInstance?: TBlockInstance }) => {
+  if (!blockInstance || !blockInstance.block) return null
+
   const duration = calculateDuration(blockInstance)
   const { title, blocks, exercises } = blockInstance.block
   return (

+ 16 - 4
frontend/src/training/training.graphql

@@ -163,8 +163,14 @@ fragment exerciseContent on Exercise {
   id
   name
   description
-  videos
-  pictures
+  videos {
+    id
+    title
+  }
+  pictures {
+    id
+    title
+  }
   targets
   baseExercise
 }
@@ -173,8 +179,14 @@ fragment blockWithoutBlocks on Block {
   id
   title
   description
-  videos
-  pictures
+  videos {
+    id
+    title
+  }
+  pictures {
+    id
+    title
+  }
   duration
   format {
     id

+ 2 - 4
frontend/src/training/types.ts

@@ -11,8 +11,7 @@ export type TTrainingArchiveItem = Pick<TrainingArchiveItem, 'id'> &
   Partial<Omit<TrainingArchiveItem, 'id'>>
 
 export type TTraining = Pick<Training, 'id' | 'type'> & Partial<Omit<Training, 'id' | 'type'>>
-export type TBlockInstance = Pick<BlockInstance, 'id' | 'block' | 'order'> &
-  Partial<Omit<BlockInstance, 'id' | 'block' | 'order'>>
+export type TBlockInstance = Pick<BlockInstance, 'id'> & Partial<Omit<BlockInstance, 'id'>>
 export type TBlock = Pick<
   Block,
   'id' | 'title' | 'exercises' | 'videos' | 'blocks' | 'tracks' | 'pictures' | 'format'
@@ -23,8 +22,7 @@ export type TBlock = Pick<
       'id' | 'title' | 'exercises' | 'videos' | 'blocks' | 'tracks' | 'pictures' | 'format'
     >
   >
-export type TExerciseInstance = Pick<ExerciseInstance, 'id' | 'exercise'> &
-  Partial<Omit<ExerciseInstance, 'id' | 'exercise'>>
+export type TExerciseInstance = Pick<ExerciseInstance, 'id'> & Partial<Omit<ExerciseInstance, 'id'>>
 export type TExercise = Pick<
   Exercise,
   'id' | 'name' | 'pictures' | 'videos' | 'targets' | 'baseExercise'

+ 44 - 60
frontend/src/training/utils.ts

@@ -1,36 +1,19 @@
 import { parse } from 'date-fns'
-import {
-  TTraining,
-  TExerciseInstance,
-  TBlockInstance,
-  TBlock,
-  TExercise,
-  TRating
-} from './types'
-import {
-  TrainingQuery,
-  Exercise,
-  Block,
-  ExerciseInstance,
-  BlockInstance
-} from '../gql'
+import { TTraining, TExerciseInstance, TBlockInstance, TBlock, TExercise, TRating } from './types'
+import { TrainingQuery, Exercise, Block, ExerciseInstance, BlockInstance } from '../gql'
 import { isArray, transform, isEqual, isObject } from 'lodash'
 
 /**
  * Takes a block of exercises and calculates the duration in seconds.
  * @param block
  */
-export function calculateDuration(
-  blocks?: TBlockInstance | TBlockInstance[]
-): number {
+export function calculateDuration(blocks?: TBlockInstance | TBlockInstance[]): number {
   if (!blocks) return 0
   const blocksArray = isArray(blocks) ? blocks : [blocks]
-  const duration = blocksArray.map(blockInstance => {
+  const duration = blocksArray.map((blockInstance) => {
     const blockRounds = blockInstance.rounds ?? 1
     const blockDuration =
-      blockInstance.block?.duration ??
-      calculateDuration(blockInstance.block?.blocks) ??
-      0
+      blockInstance.block?.duration ?? calculateDuration(blockInstance.block?.blocks) ?? 0
     const blockRest = blockInstance.block?.rest ?? 0
     return blockRounds * (blockDuration + blockRest)
   })
@@ -42,9 +25,7 @@ export function calculateDuration(
  * @param seconds
  */
 export function formatTime(seconds: number) {
-  return `${Math.floor(seconds / 60)}:${(seconds % 60)
-    .toString()
-    .padStart(2, '0')}`
+  return `${Math.floor(seconds / 60)}:${(seconds % 60).toString().padStart(2, '0')}`
 }
 
 /**
@@ -54,7 +35,7 @@ export function formatTime(seconds: number) {
  */
 export function printExercises(exercises: TExerciseInstance[]) {
   return exercises
-    .map(exerciseInstance =>
+    .map((exerciseInstance) =>
       exerciseInstance.repetitions && exerciseInstance.repetitions > 1
         ? `${exerciseInstance.repetitions}x ${exerciseInstance.exercise?.name}`
         : exerciseInstance.exercise?.name
@@ -68,10 +49,7 @@ export function printExercises(exercises: TExerciseInstance[]) {
  */
 export function calculateRating(ratings: TRating[]) {
   const numberOfRatings = ratings.length
-  const sumOfRatings = ratings.reduce(
-    (accumulator, rating) => accumulator + rating.value,
-    0
-  )
+  const sumOfRatings = ratings.reduce((accumulator, rating) => accumulator + rating.value, 0)
   return numberOfRatings ? sumOfRatings / numberOfRatings : '-'
 }
 
@@ -84,7 +62,7 @@ const Weekdays = {
   Thursday: 4,
   Friday: 5,
   Saturday: 6,
-  Sunday: 0
+  Sunday: 0,
 }
 type TWeekdays = keyof typeof Weekdays
 /**
@@ -94,9 +72,7 @@ type TWeekdays = keyof typeof Weekdays
  */
 export function nextTime(weekDay: TWeekdays, time: string) {
   const nextTime = parse(time, 'HH:mm', new Date())
-  nextTime.setDate(
-    nextTime.getDate() + ((Weekdays[weekDay] + 7 - nextTime.getDay()) % 7)
-  )
+  nextTime.setDate(nextTime.getDate() + ((Weekdays[weekDay] + 7 - nextTime.getDay()) % 7))
   return nextTime.toISOString()
 }
 
@@ -115,7 +91,7 @@ export function emptyExercise(input?: Partial<Exercise>) {
     videos: [],
     pictures: [],
     targets: [],
-    baseExercise: []
+    baseExercise: [],
   }
   return { ...emptyExercise, ...input }
 }
@@ -124,7 +100,7 @@ export function emptyExerciseInstance(input?: Partial<ExerciseInstance>) {
   const emptyExerciseInstance: TExerciseInstance = {
     id: randomID(),
     order: 0,
-    exercise: emptyExercise()
+    exercise: emptyExercise(),
   }
   return { ...emptyExerciseInstance, ...input }
 }
@@ -137,7 +113,7 @@ export function emptyBlock(input?: Partial<Block>) {
     videos: [],
     pictures: [],
     blocks: [],
-    exercises: []
+    exercises: [],
   }
   return { ...emptyBlock, ...input }
 }
@@ -145,8 +121,7 @@ export function emptyBlock(input?: Partial<Block>) {
 export function emptyBlockInstance(input?: Partial<BlockInstance>) {
   const emptyBlockInstance: TBlockInstance = {
     id: randomID(),
-    block: emptyBlock(),
-    order: 0
+    order: 0,
   }
   return { ...emptyBlockInstance, ...input }
 }
@@ -159,7 +134,7 @@ export function emptyTraining(input?: TTraining) {
     createdAt: '',
     trainingDate: nextTime('Tuesday', '11:45'),
     published: false,
-    blocks: []
+    blocks: [],
   }
   return { ...emptyTraining, ...input }
 }
@@ -169,7 +144,7 @@ export function collectMutations(arr: any[]) {
   const connect: any[] = []
   const del: any[] = []
   const update: any[] = []
-  arr.forEach(val => {
+  arr.forEach((val) => {
     if (val?.connect) connect.push(val.connect)
     if (val?.create) create.push(val.create)
     if (val?.delete) del.push(val.delete)
@@ -192,10 +167,7 @@ export function collectMutations(arr: any[]) {
 }
 
 function isNotInDB(key: any, val: any) {
-  return (
-    key === '__typename' ||
-    (key === 'id' && typeof val === 'string' && val.startsWith('++'))
-  )
+  return key === '__typename' || (key === 'id' && typeof val === 'string' && val.startsWith('++'))
 }
 
 export function transformArrayToDB(acc: any, val: any, key: any, object: any) {
@@ -219,8 +191,8 @@ export function transformArrayToDB(acc: any, val: any, key: any, object: any) {
           acc[key] = {
             update: {
               where: { id: id.substr(2) },
-              data: transform(data, transformArrayToDB)
-            }
+              data: transform(data, transformArrayToDB),
+            },
           }
         }
       } else {
@@ -237,7 +209,7 @@ function isScalarArray(object: any) {
   return (
     isArray(object) &&
     object.every(
-      item =>
+      (item) =>
         typeof item === 'string' ||
         typeof item === 'number' ||
         typeof item === 'boolean' ||
@@ -263,20 +235,15 @@ export function diffDB(newObject: any = {}, oldObject: any = {}) {
   }
 
   // transform everything else
-  const transformResult = transform(
-    newObject,
-    (result: any, value: any, key: string) => {
-      if (!isEqual(value, oldObject[key])) {
-        const newValue =
-          isObject(value) && isObject(oldObject[key])
-            ? diffDB(value, oldObject[key])
-            : value
-        if (newValue !== undefined) {
-          result[key] = newValue
-        }
+  const transformResult = transform(newObject, (result: any, value: any, key: string) => {
+    if (!isEqual(value, oldObject[key])) {
+      const newValue =
+        isObject(value) && isObject(oldObject[key]) ? diffDB(value, oldObject[key]) : value
+      if (newValue !== undefined) {
+        result[key] = newValue
       }
     }
-  )
+  })
 
   // detects updates, return object if there was a change.
   if (Object.keys(transformResult).length > 0) {
@@ -294,3 +261,20 @@ export function prepareDataForDB<T>(formData: T, dbData: T) {
   const gqlData = transform(changes, transformArrayToDB)
   return gqlData
 }
+
+export function countBlocksAndExercises(block?: TBlock): [number, number] {
+  const [blocks, exercises] = block?.blocks?.reduce(
+    (accumulator, blockInstance) => {
+      const [accumulatedBlocks, accumulatedExercises] = accumulator
+      const [subBlocks, subExercises] = countBlocksAndExercises(blockInstance.block)
+      const currentBlocks = blockInstance.block?.blocks?.length ?? 0
+      const currentExercises = blockInstance.block?.exercises?.length ?? 0
+      return [
+        accumulatedBlocks + currentBlocks + subBlocks,
+        accumulatedExercises + currentExercises + subExercises,
+      ]
+    },
+    [0, 0]
+  ) ?? [0, 0]
+  return [blocks + (block?.blocks?.length ?? 0), exercises + (block?.exercises?.length ?? 0)]
+}

+ 9 - 5
frontend/src/user/components/DeleteUserButton.tsx

@@ -5,13 +5,17 @@ interface DeleteUserProps {
   title?: string
 }
 
-const DeleteUserButton = ({ title, user: { email } }: DeleteUserProps) => {
+const DeleteUserButton = ({ title, user: { id } }: DeleteUserProps) => {
   const [deleteUser, { loading, error }] = useUserDeleteMutation()
   return (
-    <button onClick={(event: React.SyntheticEvent) => {
-      deleteUser({ variables: { email } })
-    }}>{title || 'Delete user'}</button>
+    <button
+      onClick={(event: React.SyntheticEvent) => {
+        deleteUser({ variables: { id } })
+      }}
+    >
+      {title || 'Delete user'}
+    </button>
   )
 }
 
-export default DeleteUserButton
+export default DeleteUserButton

+ 3 - 0
frontend/src/user/types.ts

@@ -0,0 +1,3 @@
+import { User } from '../gql'
+
+export type TUser = Pick<User, 'id' | 'email'> & Partial<Omit<User, 'id' | 'email'>>

+ 10 - 4
frontend/src/user/user.graphql

@@ -49,14 +49,14 @@ mutation ResetPassword($token: String!, $password: String!) {
   }
 }
 
-mutation UserDelete($email: String!) {
-  deleteUser(email: $email) {
+mutation UserDelete($id: ID!) {
+  deleteUser(id: $id) {
     id
   }
 }
 
-mutation UserUpdate($email: String!, $data: UserUpdateInput!) {
-  updateUser(email: $email, data: $data) {
+mutation UserUpdate($id: ID!, $data: UserUpdateInput!) {
+  updateUser(id: $id, data: $data) {
     id
     name
     email
@@ -64,3 +64,9 @@ mutation UserUpdate($email: String!, $data: UserUpdateInput!) {
     interests
   }
 }
+
+mutation updatePermissions($id: ID!, $permissions: [Permission!]!) {
+  updatePermissions(id: $id, permissions: $permissions) {
+    id
+  }
+}

+ 2 - 1
frontend/tsconfig.jest.json

@@ -3,6 +3,7 @@
   "compilerOptions": {
     "jsx": "react",
     "allowJs": true,
-    "target": "es5"
+    "target": "es5",
+    "module": "CommonJS"
   }
 }

+ 3 - 2
proxy/nginx.conf

@@ -13,7 +13,7 @@ http{
     location /upload_files {
       root                /www;
       autoindex           off;
-      sendfile_max_chunk  100k;
+      sendfile_max_chunk  200k;
     }
 
     location / {
@@ -34,6 +34,7 @@ http{
       proxy_set_header   X-Forwarded-Host $server_name;
       proxy_pass      http://backend:4000/graphql;
       proxy_redirect  off;
+      client_max_body_size	100m;
     }
   }
-}
+}

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません