Browse Source

config broken.

Tomi Cvetic 4 years ago
parent
commit
1586d060e6
61 changed files with 3536 additions and 855 deletions
  1. 440 0
      backend/database/generated/prisma-client/index.ts
  2. 276 0
      backend/database/generated/prisma-client/prisma-schema.ts
  3. 543 11
      backend/database/generated/prisma.graphql
  4. 14 0
      backend/datamodel.prisma
  5. 4 6
      backend/index.ts
  6. 361 9
      backend/package-lock.json
  7. 2 0
      backend/package.json
  8. 17 0
      backend/schema.graphql
  9. 3 0
      backend/src/file/constants.ts
  10. 3 0
      backend/src/file/index.ts
  11. 118 0
      backend/src/file/resolvers.ts
  12. 12 2
      backend/src/training/resolvers.ts
  13. 3 3
      frontend/initial-data.ts
  14. 10 0
      frontend/jest.config.js
  15. 309 264
      frontend/package-lock.json
  16. 23 20
      frontend/package.json
  17. 11 0
      frontend/pages/admin/index.tsx
  18. 0 1
      frontend/pages/admin/training/[id].tsx
  19. 10 4
      frontend/pages/index.tsx
  20. 0 8
      frontend/pages/timer.tsx
  21. 19 0
      frontend/pages/timer/[id].tsx
  22. 10 2
      frontend/pages/training/[id].tsx
  23. 1 1
      frontend/src/app/components/Footer.tsx
  24. 24 1
      frontend/src/app/components/Header.tsx
  25. 49 30
      frontend/src/app/components/Logo.tsx
  26. 171 51
      frontend/src/app/components/Nav.tsx
  27. 0 2
      frontend/src/app/components/Page.tsx
  28. 14 5
      frontend/src/form/__tests__/useFormHandler.test.tsx
  29. 1 1
      frontend/src/form/components/TextInput.tsx
  30. 312 21
      frontend/src/gql/index.tsx
  31. 2 26
      frontend/src/modal/components/Modal.tsx
  32. 28 0
      frontend/src/modal/styles/index.ts
  33. 4 71
      frontend/src/sortable/components/SortableList.tsx
  34. 50 0
      frontend/src/sortable/styles/index.ts
  35. 5 8
      frontend/src/styles/global.ts
  36. 2 1
      frontend/src/styles/theme.ts
  37. 2 2
      frontend/src/timer/components/Timer.tsx
  38. 17 13
      frontend/src/timer/utils.ts
  39. 63 0
      frontend/src/training/__tests__/utils.test.ts
  40. 23 6
      frontend/src/training/components/BlockInputs.tsx
  41. 64 0
      frontend/src/training/components/BlockSelector.tsx
  42. 7 3
      frontend/src/training/components/ExerciseComposition.tsx
  43. 8 0
      frontend/src/training/components/ExerciseInputs.tsx
  44. 2 2
      frontend/src/training/components/ExerciseInstanceInputs.tsx
  45. 66 0
      frontend/src/training/components/ExerciseSelector.tsx
  46. 2 2
      frontend/src/training/components/FormatSelector.tsx
  47. 11 9
      frontend/src/training/components/Training.tsx
  48. 30 17
      frontend/src/training/components/TrainingBlock.tsx
  49. 38 20
      frontend/src/training/components/TrainingMeta.tsx
  50. 1 1
      frontend/src/training/components/TrainingTypeSelector.tsx
  51. 125 15
      frontend/src/training/training.graphql
  52. 41 63
      frontend/src/training/types.ts
  53. 64 53
      frontend/src/training/utils.ts
  54. 33 17
      frontend/src/user/components/LoginForm.tsx
  55. 9 13
      frontend/src/user/components/LogoutButton.tsx
  56. 45 0
      frontend/src/user/components/UserNav.tsx
  57. 2 1
      frontend/src/user/components/__tests__/DeleteUserButton.test.tsx
  58. 23 6
      frontend/src/user/hooks.tsx
  59. 0 62
      frontend/src/user/user.js
  60. 6 0
      frontend/tsconfig.jest.json
  61. 3 2
      frontend/tsconfig.json

+ 440 - 0
backend/database/generated/prisma-client/index.ts

@@ -21,6 +21,7 @@ export interface Exists {
   comment: (where?: CommentWhereInput) => Promise<boolean>;
   exercise: (where?: ExerciseWhereInput) => Promise<boolean>;
   exerciseInstance: (where?: ExerciseInstanceWhereInput) => Promise<boolean>;
+  file: (where?: FileWhereInput) => Promise<boolean>;
   format: (where?: FormatWhereInput) => Promise<boolean>;
   rating: (where?: RatingWhereInput) => Promise<boolean>;
   track: (where?: TrackWhereInput) => Promise<boolean>;
@@ -147,6 +148,25 @@ export interface Prisma {
     first?: Int;
     last?: Int;
   }) => ExerciseInstanceConnectionPromise;
+  file: (where: FileWhereUniqueInput) => FileNullablePromise;
+  files: (args?: {
+    where?: FileWhereInput;
+    orderBy?: FileOrderByInput;
+    skip?: Int;
+    after?: String;
+    before?: String;
+    first?: Int;
+    last?: Int;
+  }) => FragmentableArray<File>;
+  filesConnection: (args?: {
+    where?: FileWhereInput;
+    orderBy?: FileOrderByInput;
+    skip?: Int;
+    after?: String;
+    before?: String;
+    first?: Int;
+    last?: Int;
+  }) => FileConnectionPromise;
   format: (where: FormatWhereUniqueInput) => FormatNullablePromise;
   formats: (args?: {
     where?: FormatWhereInput;
@@ -359,6 +379,22 @@ export interface Prisma {
   deleteManyExerciseInstances: (
     where?: ExerciseInstanceWhereInput
   ) => BatchPayloadPromise;
+  createFile: (data: FileCreateInput) => FilePromise;
+  updateFile: (args: {
+    data: FileUpdateInput;
+    where: FileWhereUniqueInput;
+  }) => FilePromise;
+  updateManyFiles: (args: {
+    data: FileUpdateManyMutationInput;
+    where?: FileWhereInput;
+  }) => BatchPayloadPromise;
+  upsertFile: (args: {
+    where: FileWhereUniqueInput;
+    create: FileCreateInput;
+    update: FileUpdateInput;
+  }) => FilePromise;
+  deleteFile: (where: FileWhereUniqueInput) => FilePromise;
+  deleteManyFiles: (where?: FileWhereInput) => BatchPayloadPromise;
   createFormat: (data: FormatCreateInput) => FormatPromise;
   updateFormat: (args: {
     data: FormatUpdateInput;
@@ -483,6 +519,9 @@ export interface Subscription {
   exerciseInstance: (
     where?: ExerciseInstanceSubscriptionWhereInput
   ) => ExerciseInstanceSubscriptionPayloadSubscription;
+  file: (
+    where?: FileSubscriptionWhereInput
+  ) => FileSubscriptionPayloadSubscription;
   format: (
     where?: FormatSubscriptionWhereInput
   ) => FormatSubscriptionPayloadSubscription;
@@ -599,6 +638,28 @@ export type ExerciseOrderByInput =
   | "description_ASC"
   | "description_DESC";
 
+export type FileOrderByInput =
+  | "id_ASC"
+  | "id_DESC"
+  | "createdAt_ASC"
+  | "createdAt_DESC"
+  | "updatedAt_ASC"
+  | "updatedAt_DESC"
+  | "path_ASC"
+  | "path_DESC"
+  | "mimetype_ASC"
+  | "mimetype_DESC"
+  | "thumbnail_ASC"
+  | "thumbnail_DESC"
+  | "filename_ASC"
+  | "filename_DESC"
+  | "encoding_ASC"
+  | "encoding_DESC"
+  | "size_ASC"
+  | "size_DESC"
+  | "comment_ASC"
+  | "comment_DESC";
+
 export type FormatOrderByInput =
   | "id_ASC"
   | "id_DESC"
@@ -1324,6 +1385,139 @@ export type ExerciseInstanceWhereUniqueInput = AtLeastOne<{
   id: Maybe<ID_Input>;
 }>;
 
+export type FileWhereUniqueInput = AtLeastOne<{
+  id: Maybe<ID_Input>;
+}>;
+
+export interface FileWhereInput {
+  id?: Maybe<ID_Input>;
+  id_not?: Maybe<ID_Input>;
+  id_in?: Maybe<ID_Input[] | ID_Input>;
+  id_not_in?: Maybe<ID_Input[] | ID_Input>;
+  id_lt?: Maybe<ID_Input>;
+  id_lte?: Maybe<ID_Input>;
+  id_gt?: Maybe<ID_Input>;
+  id_gte?: Maybe<ID_Input>;
+  id_contains?: Maybe<ID_Input>;
+  id_not_contains?: Maybe<ID_Input>;
+  id_starts_with?: Maybe<ID_Input>;
+  id_not_starts_with?: Maybe<ID_Input>;
+  id_ends_with?: Maybe<ID_Input>;
+  id_not_ends_with?: Maybe<ID_Input>;
+  createdAt?: Maybe<DateTimeInput>;
+  createdAt_not?: Maybe<DateTimeInput>;
+  createdAt_in?: Maybe<DateTimeInput[] | DateTimeInput>;
+  createdAt_not_in?: Maybe<DateTimeInput[] | DateTimeInput>;
+  createdAt_lt?: Maybe<DateTimeInput>;
+  createdAt_lte?: Maybe<DateTimeInput>;
+  createdAt_gt?: Maybe<DateTimeInput>;
+  createdAt_gte?: Maybe<DateTimeInput>;
+  updatedAt?: Maybe<DateTimeInput>;
+  updatedAt_not?: Maybe<DateTimeInput>;
+  updatedAt_in?: Maybe<DateTimeInput[] | DateTimeInput>;
+  updatedAt_not_in?: Maybe<DateTimeInput[] | DateTimeInput>;
+  updatedAt_lt?: Maybe<DateTimeInput>;
+  updatedAt_lte?: Maybe<DateTimeInput>;
+  updatedAt_gt?: Maybe<DateTimeInput>;
+  updatedAt_gte?: Maybe<DateTimeInput>;
+  path?: Maybe<String>;
+  path_not?: Maybe<String>;
+  path_in?: Maybe<String[] | String>;
+  path_not_in?: Maybe<String[] | String>;
+  path_lt?: Maybe<String>;
+  path_lte?: Maybe<String>;
+  path_gt?: Maybe<String>;
+  path_gte?: Maybe<String>;
+  path_contains?: Maybe<String>;
+  path_not_contains?: Maybe<String>;
+  path_starts_with?: Maybe<String>;
+  path_not_starts_with?: Maybe<String>;
+  path_ends_with?: Maybe<String>;
+  path_not_ends_with?: Maybe<String>;
+  mimetype?: Maybe<String>;
+  mimetype_not?: Maybe<String>;
+  mimetype_in?: Maybe<String[] | String>;
+  mimetype_not_in?: Maybe<String[] | String>;
+  mimetype_lt?: Maybe<String>;
+  mimetype_lte?: Maybe<String>;
+  mimetype_gt?: Maybe<String>;
+  mimetype_gte?: Maybe<String>;
+  mimetype_contains?: Maybe<String>;
+  mimetype_not_contains?: Maybe<String>;
+  mimetype_starts_with?: Maybe<String>;
+  mimetype_not_starts_with?: Maybe<String>;
+  mimetype_ends_with?: Maybe<String>;
+  mimetype_not_ends_with?: Maybe<String>;
+  user?: Maybe<UserWhereInput>;
+  thumbnail?: Maybe<String>;
+  thumbnail_not?: Maybe<String>;
+  thumbnail_in?: Maybe<String[] | String>;
+  thumbnail_not_in?: Maybe<String[] | String>;
+  thumbnail_lt?: Maybe<String>;
+  thumbnail_lte?: Maybe<String>;
+  thumbnail_gt?: Maybe<String>;
+  thumbnail_gte?: Maybe<String>;
+  thumbnail_contains?: Maybe<String>;
+  thumbnail_not_contains?: Maybe<String>;
+  thumbnail_starts_with?: Maybe<String>;
+  thumbnail_not_starts_with?: Maybe<String>;
+  thumbnail_ends_with?: Maybe<String>;
+  thumbnail_not_ends_with?: Maybe<String>;
+  filename?: Maybe<String>;
+  filename_not?: Maybe<String>;
+  filename_in?: Maybe<String[] | String>;
+  filename_not_in?: Maybe<String[] | String>;
+  filename_lt?: Maybe<String>;
+  filename_lte?: Maybe<String>;
+  filename_gt?: Maybe<String>;
+  filename_gte?: Maybe<String>;
+  filename_contains?: Maybe<String>;
+  filename_not_contains?: Maybe<String>;
+  filename_starts_with?: Maybe<String>;
+  filename_not_starts_with?: Maybe<String>;
+  filename_ends_with?: Maybe<String>;
+  filename_not_ends_with?: Maybe<String>;
+  encoding?: Maybe<String>;
+  encoding_not?: Maybe<String>;
+  encoding_in?: Maybe<String[] | String>;
+  encoding_not_in?: Maybe<String[] | String>;
+  encoding_lt?: Maybe<String>;
+  encoding_lte?: Maybe<String>;
+  encoding_gt?: Maybe<String>;
+  encoding_gte?: Maybe<String>;
+  encoding_contains?: Maybe<String>;
+  encoding_not_contains?: Maybe<String>;
+  encoding_starts_with?: Maybe<String>;
+  encoding_not_starts_with?: Maybe<String>;
+  encoding_ends_with?: Maybe<String>;
+  encoding_not_ends_with?: Maybe<String>;
+  size?: Maybe<Int>;
+  size_not?: Maybe<Int>;
+  size_in?: Maybe<Int[] | Int>;
+  size_not_in?: Maybe<Int[] | Int>;
+  size_lt?: Maybe<Int>;
+  size_lte?: Maybe<Int>;
+  size_gt?: Maybe<Int>;
+  size_gte?: Maybe<Int>;
+  comment?: Maybe<String>;
+  comment_not?: Maybe<String>;
+  comment_in?: Maybe<String[] | String>;
+  comment_not_in?: Maybe<String[] | String>;
+  comment_lt?: Maybe<String>;
+  comment_lte?: Maybe<String>;
+  comment_gt?: Maybe<String>;
+  comment_gte?: Maybe<String>;
+  comment_contains?: Maybe<String>;
+  comment_not_contains?: Maybe<String>;
+  comment_starts_with?: Maybe<String>;
+  comment_not_starts_with?: Maybe<String>;
+  comment_ends_with?: Maybe<String>;
+  comment_not_ends_with?: Maybe<String>;
+  AND?: Maybe<FileWhereInput[] | FileWhereInput>;
+  OR?: Maybe<FileWhereInput[] | FileWhereInput>;
+  NOT?: Maybe<FileWhereInput[] | FileWhereInput>;
+}
+
 export type FormatWhereUniqueInput = AtLeastOne<{
   id: Maybe<ID_Input>;
 }>;
@@ -2719,6 +2913,56 @@ export interface ExerciseInstanceUpdateManyMutationInput {
   variation?: Maybe<String>;
 }
 
+export interface FileCreateInput {
+  id?: Maybe<ID_Input>;
+  path: String;
+  mimetype: String;
+  user: UserCreateOneInput;
+  thumbnail?: Maybe<String>;
+  filename: String;
+  encoding: String;
+  size: Int;
+  comment?: Maybe<String>;
+}
+
+export interface UserCreateOneInput {
+  create?: Maybe<UserCreateInput>;
+  connect?: Maybe<UserWhereUniqueInput>;
+}
+
+export interface FileUpdateInput {
+  path?: Maybe<String>;
+  mimetype?: Maybe<String>;
+  user?: Maybe<UserUpdateOneRequiredInput>;
+  thumbnail?: Maybe<String>;
+  filename?: Maybe<String>;
+  encoding?: Maybe<String>;
+  size?: Maybe<Int>;
+  comment?: Maybe<String>;
+}
+
+export interface UserUpdateOneRequiredInput {
+  create?: Maybe<UserCreateInput>;
+  update?: Maybe<UserUpdateDataInput>;
+  upsert?: Maybe<UserUpsertNestedInput>;
+  connect?: Maybe<UserWhereUniqueInput>;
+}
+
+export interface UserUpsertNestedInput {
+  update: UserUpdateDataInput;
+  create: UserCreateInput;
+}
+
+export interface FileUpdateManyMutationInput {
+  path?: Maybe<String>;
+  mimetype?: Maybe<String>;
+  thumbnail?: Maybe<String>;
+  filename?: Maybe<String>;
+  encoding?: Maybe<String>;
+  size?: Maybe<Int>;
+  comment?: Maybe<String>;
+}
+
 export interface FormatUpdateInput {
   name?: Maybe<String>;
   description?: Maybe<String>;
@@ -2963,6 +3207,17 @@ export interface ExerciseInstanceSubscriptionWhereInput {
   >;
 }
 
+export interface FileSubscriptionWhereInput {
+  mutation_in?: Maybe<MutationType[] | MutationType>;
+  updatedFields_contains?: Maybe<String>;
+  updatedFields_contains_every?: Maybe<String[] | String>;
+  updatedFields_contains_some?: Maybe<String[] | String>;
+  node?: Maybe<FileWhereInput>;
+  AND?: Maybe<FileSubscriptionWhereInput[] | FileSubscriptionWhereInput>;
+  OR?: Maybe<FileSubscriptionWhereInput[] | FileSubscriptionWhereInput>;
+  NOT?: Maybe<FileSubscriptionWhereInput[] | FileSubscriptionWhereInput>;
+}
+
 export interface FormatSubscriptionWhereInput {
   mutation_in?: Maybe<MutationType[] | MutationType>;
   updatedFields_contains?: Maybe<String>;
@@ -4015,6 +4270,119 @@ export interface AggregateExerciseInstanceSubscription
   count: () => Promise<AsyncIterator<Int>>;
 }
 
+export interface File {
+  id: ID_Output;
+  createdAt: DateTimeOutput;
+  updatedAt: DateTimeOutput;
+  path: String;
+  mimetype: String;
+  thumbnail?: String;
+  filename: String;
+  encoding: String;
+  size: Int;
+  comment?: String;
+}
+
+export interface FilePromise extends Promise<File>, Fragmentable {
+  id: () => Promise<ID_Output>;
+  createdAt: () => Promise<DateTimeOutput>;
+  updatedAt: () => Promise<DateTimeOutput>;
+  path: () => Promise<String>;
+  mimetype: () => Promise<String>;
+  user: <T = UserPromise>() => T;
+  thumbnail: () => Promise<String>;
+  filename: () => Promise<String>;
+  encoding: () => Promise<String>;
+  size: () => Promise<Int>;
+  comment: () => Promise<String>;
+}
+
+export interface FileSubscription
+  extends Promise<AsyncIterator<File>>,
+    Fragmentable {
+  id: () => Promise<AsyncIterator<ID_Output>>;
+  createdAt: () => Promise<AsyncIterator<DateTimeOutput>>;
+  updatedAt: () => Promise<AsyncIterator<DateTimeOutput>>;
+  path: () => Promise<AsyncIterator<String>>;
+  mimetype: () => Promise<AsyncIterator<String>>;
+  user: <T = UserSubscription>() => T;
+  thumbnail: () => Promise<AsyncIterator<String>>;
+  filename: () => Promise<AsyncIterator<String>>;
+  encoding: () => Promise<AsyncIterator<String>>;
+  size: () => Promise<AsyncIterator<Int>>;
+  comment: () => Promise<AsyncIterator<String>>;
+}
+
+export interface FileNullablePromise
+  extends Promise<File | null>,
+    Fragmentable {
+  id: () => Promise<ID_Output>;
+  createdAt: () => Promise<DateTimeOutput>;
+  updatedAt: () => Promise<DateTimeOutput>;
+  path: () => Promise<String>;
+  mimetype: () => Promise<String>;
+  user: <T = UserPromise>() => T;
+  thumbnail: () => Promise<String>;
+  filename: () => Promise<String>;
+  encoding: () => Promise<String>;
+  size: () => Promise<Int>;
+  comment: () => Promise<String>;
+}
+
+export interface FileConnection {
+  pageInfo: PageInfo;
+  edges: FileEdge[];
+}
+
+export interface FileConnectionPromise
+  extends Promise<FileConnection>,
+    Fragmentable {
+  pageInfo: <T = PageInfoPromise>() => T;
+  edges: <T = FragmentableArray<FileEdge>>() => T;
+  aggregate: <T = AggregateFilePromise>() => T;
+}
+
+export interface FileConnectionSubscription
+  extends Promise<AsyncIterator<FileConnection>>,
+    Fragmentable {
+  pageInfo: <T = PageInfoSubscription>() => T;
+  edges: <T = Promise<AsyncIterator<FileEdgeSubscription>>>() => T;
+  aggregate: <T = AggregateFileSubscription>() => T;
+}
+
+export interface FileEdge {
+  node: File;
+  cursor: String;
+}
+
+export interface FileEdgePromise extends Promise<FileEdge>, Fragmentable {
+  node: <T = FilePromise>() => T;
+  cursor: () => Promise<String>;
+}
+
+export interface FileEdgeSubscription
+  extends Promise<AsyncIterator<FileEdge>>,
+    Fragmentable {
+  node: <T = FileSubscription>() => T;
+  cursor: () => Promise<AsyncIterator<String>>;
+}
+
+export interface AggregateFile {
+  count: Int;
+}
+
+export interface AggregateFilePromise
+  extends Promise<AggregateFile>,
+    Fragmentable {
+  count: () => Promise<Int>;
+}
+
+export interface AggregateFileSubscription
+  extends Promise<AsyncIterator<AggregateFile>>,
+    Fragmentable {
+  count: () => Promise<AsyncIterator<Int>>;
+}
+
 export interface FormatConnection {
   pageInfo: PageInfo;
   edges: FormatEdge[];
@@ -4624,6 +4992,74 @@ export interface ExerciseInstancePreviousValuesSubscription
   variation: () => Promise<AsyncIterator<String>>;
 }
 
+export interface FileSubscriptionPayload {
+  mutation: MutationType;
+  node: File;
+  updatedFields: String[];
+  previousValues: FilePreviousValues;
+}
+
+export interface FileSubscriptionPayloadPromise
+  extends Promise<FileSubscriptionPayload>,
+    Fragmentable {
+  mutation: () => Promise<MutationType>;
+  node: <T = FilePromise>() => T;
+  updatedFields: () => Promise<String[]>;
+  previousValues: <T = FilePreviousValuesPromise>() => T;
+}
+
+export interface FileSubscriptionPayloadSubscription
+  extends Promise<AsyncIterator<FileSubscriptionPayload>>,
+    Fragmentable {
+  mutation: () => Promise<AsyncIterator<MutationType>>;
+  node: <T = FileSubscription>() => T;
+  updatedFields: () => Promise<AsyncIterator<String[]>>;
+  previousValues: <T = FilePreviousValuesSubscription>() => T;
+}
+
+export interface FilePreviousValues {
+  id: ID_Output;
+  createdAt: DateTimeOutput;
+  updatedAt: DateTimeOutput;
+  path: String;
+  mimetype: String;
+  thumbnail?: String;
+  filename: String;
+  encoding: String;
+  size: Int;
+  comment?: String;
+}
+
+export interface FilePreviousValuesPromise
+  extends Promise<FilePreviousValues>,
+    Fragmentable {
+  id: () => Promise<ID_Output>;
+  createdAt: () => Promise<DateTimeOutput>;
+  updatedAt: () => Promise<DateTimeOutput>;
+  path: () => Promise<String>;
+  mimetype: () => Promise<String>;
+  thumbnail: () => Promise<String>;
+  filename: () => Promise<String>;
+  encoding: () => Promise<String>;
+  size: () => Promise<Int>;
+  comment: () => Promise<String>;
+}
+
+export interface FilePreviousValuesSubscription
+  extends Promise<AsyncIterator<FilePreviousValues>>,
+    Fragmentable {
+  id: () => Promise<AsyncIterator<ID_Output>>;
+  createdAt: () => Promise<AsyncIterator<DateTimeOutput>>;
+  updatedAt: () => Promise<AsyncIterator<DateTimeOutput>>;
+  path: () => Promise<AsyncIterator<String>>;
+  mimetype: () => Promise<AsyncIterator<String>>;
+  thumbnail: () => Promise<AsyncIterator<String>>;
+  filename: () => Promise<AsyncIterator<String>>;
+  encoding: () => Promise<AsyncIterator<String>>;
+  size: () => Promise<AsyncIterator<Int>>;
+  comment: () => Promise<AsyncIterator<String>>;
+}
+
 export interface FormatSubscriptionPayload {
   mutation: MutationType;
   node: Format;
@@ -4996,6 +5432,10 @@ export const models: Model[] = [
     name: "Permission",
     embedded: false
   },
+  {
+    name: "File",
+    embedded: false
+  },
   {
     name: "Training",
     embedded: false

+ 276 - 0
backend/database/generated/prisma-client/prisma-schema.ts

@@ -22,6 +22,10 @@ type AggregateExerciseInstance {
   count: Int!
 }
 
+type AggregateFile {
+  count: Int!
+}
+
 type AggregateFormat {
   count: Int!
 }
@@ -1303,6 +1307,251 @@ input ExerciseWhereUniqueInput {
   id: ID
 }
 
+type File {
+  id: ID!
+  createdAt: DateTime!
+  updatedAt: DateTime!
+  path: String!
+  mimetype: String!
+  user: User!
+  thumbnail: String
+  filename: String!
+  encoding: String!
+  size: Int!
+  comment: String
+}
+
+type FileConnection {
+  pageInfo: PageInfo!
+  edges: [FileEdge]!
+  aggregate: AggregateFile!
+}
+
+input FileCreateInput {
+  id: ID
+  path: String!
+  mimetype: String!
+  user: UserCreateOneInput!
+  thumbnail: String
+  filename: String!
+  encoding: String!
+  size: Int!
+  comment: String
+}
+
+type FileEdge {
+  node: File!
+  cursor: String!
+}
+
+enum FileOrderByInput {
+  id_ASC
+  id_DESC
+  createdAt_ASC
+  createdAt_DESC
+  updatedAt_ASC
+  updatedAt_DESC
+  path_ASC
+  path_DESC
+  mimetype_ASC
+  mimetype_DESC
+  thumbnail_ASC
+  thumbnail_DESC
+  filename_ASC
+  filename_DESC
+  encoding_ASC
+  encoding_DESC
+  size_ASC
+  size_DESC
+  comment_ASC
+  comment_DESC
+}
+
+type FilePreviousValues {
+  id: ID!
+  createdAt: DateTime!
+  updatedAt: DateTime!
+  path: String!
+  mimetype: String!
+  thumbnail: String
+  filename: String!
+  encoding: String!
+  size: Int!
+  comment: String
+}
+
+type FileSubscriptionPayload {
+  mutation: MutationType!
+  node: File
+  updatedFields: [String!]
+  previousValues: FilePreviousValues
+}
+
+input FileSubscriptionWhereInput {
+  mutation_in: [MutationType!]
+  updatedFields_contains: String
+  updatedFields_contains_every: [String!]
+  updatedFields_contains_some: [String!]
+  node: FileWhereInput
+  AND: [FileSubscriptionWhereInput!]
+  OR: [FileSubscriptionWhereInput!]
+  NOT: [FileSubscriptionWhereInput!]
+}
+
+input FileUpdateInput {
+  path: String
+  mimetype: String
+  user: UserUpdateOneRequiredInput
+  thumbnail: String
+  filename: String
+  encoding: String
+  size: Int
+  comment: String
+}
+
+input FileUpdateManyMutationInput {
+  path: String
+  mimetype: String
+  thumbnail: String
+  filename: String
+  encoding: String
+  size: Int
+  comment: String
+}
+
+input FileWhereInput {
+  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
+  createdAt: DateTime
+  createdAt_not: DateTime
+  createdAt_in: [DateTime!]
+  createdAt_not_in: [DateTime!]
+  createdAt_lt: DateTime
+  createdAt_lte: DateTime
+  createdAt_gt: DateTime
+  createdAt_gte: DateTime
+  updatedAt: DateTime
+  updatedAt_not: DateTime
+  updatedAt_in: [DateTime!]
+  updatedAt_not_in: [DateTime!]
+  updatedAt_lt: DateTime
+  updatedAt_lte: DateTime
+  updatedAt_gt: DateTime
+  updatedAt_gte: DateTime
+  path: String
+  path_not: String
+  path_in: [String!]
+  path_not_in: [String!]
+  path_lt: String
+  path_lte: String
+  path_gt: String
+  path_gte: String
+  path_contains: String
+  path_not_contains: String
+  path_starts_with: String
+  path_not_starts_with: String
+  path_ends_with: String
+  path_not_ends_with: String
+  mimetype: String
+  mimetype_not: String
+  mimetype_in: [String!]
+  mimetype_not_in: [String!]
+  mimetype_lt: String
+  mimetype_lte: String
+  mimetype_gt: String
+  mimetype_gte: String
+  mimetype_contains: String
+  mimetype_not_contains: String
+  mimetype_starts_with: String
+  mimetype_not_starts_with: String
+  mimetype_ends_with: String
+  mimetype_not_ends_with: String
+  user: UserWhereInput
+  thumbnail: String
+  thumbnail_not: String
+  thumbnail_in: [String!]
+  thumbnail_not_in: [String!]
+  thumbnail_lt: String
+  thumbnail_lte: String
+  thumbnail_gt: String
+  thumbnail_gte: String
+  thumbnail_contains: String
+  thumbnail_not_contains: String
+  thumbnail_starts_with: String
+  thumbnail_not_starts_with: String
+  thumbnail_ends_with: String
+  thumbnail_not_ends_with: String
+  filename: String
+  filename_not: String
+  filename_in: [String!]
+  filename_not_in: [String!]
+  filename_lt: String
+  filename_lte: String
+  filename_gt: String
+  filename_gte: String
+  filename_contains: String
+  filename_not_contains: String
+  filename_starts_with: String
+  filename_not_starts_with: String
+  filename_ends_with: String
+  filename_not_ends_with: String
+  encoding: String
+  encoding_not: String
+  encoding_in: [String!]
+  encoding_not_in: [String!]
+  encoding_lt: String
+  encoding_lte: String
+  encoding_gt: String
+  encoding_gte: String
+  encoding_contains: String
+  encoding_not_contains: String
+  encoding_starts_with: String
+  encoding_not_starts_with: String
+  encoding_ends_with: String
+  encoding_not_ends_with: String
+  size: Int
+  size_not: Int
+  size_in: [Int!]
+  size_not_in: [Int!]
+  size_lt: Int
+  size_lte: Int
+  size_gt: Int
+  size_gte: Int
+  comment: String
+  comment_not: String
+  comment_in: [String!]
+  comment_not_in: [String!]
+  comment_lt: String
+  comment_lte: String
+  comment_gt: String
+  comment_gte: String
+  comment_contains: String
+  comment_not_contains: String
+  comment_starts_with: String
+  comment_not_starts_with: String
+  comment_ends_with: String
+  comment_not_ends_with: String
+  AND: [FileWhereInput!]
+  OR: [FileWhereInput!]
+  NOT: [FileWhereInput!]
+}
+
+input FileWhereUniqueInput {
+  id: ID
+}
+
 type Format {
   id: ID!
   name: String!
@@ -1476,6 +1725,12 @@ type Mutation {
   upsertExerciseInstance(where: ExerciseInstanceWhereUniqueInput!, create: ExerciseInstanceCreateInput!, update: ExerciseInstanceUpdateInput!): ExerciseInstance!
   deleteExerciseInstance(where: ExerciseInstanceWhereUniqueInput!): ExerciseInstance
   deleteManyExerciseInstances(where: ExerciseInstanceWhereInput): BatchPayload!
+  createFile(data: FileCreateInput!): File!
+  updateFile(data: FileUpdateInput!, where: FileWhereUniqueInput!): File
+  updateManyFiles(data: FileUpdateManyMutationInput!, where: FileWhereInput): BatchPayload!
+  upsertFile(where: FileWhereUniqueInput!, create: FileCreateInput!, update: FileUpdateInput!): File!
+  deleteFile(where: FileWhereUniqueInput!): File
+  deleteManyFiles(where: FileWhereInput): BatchPayload!
   createFormat(data: FormatCreateInput!): Format!
   updateFormat(data: FormatUpdateInput!, where: FormatWhereUniqueInput!): Format
   updateManyFormats(data: FormatUpdateManyMutationInput!, where: FormatWhereInput): BatchPayload!
@@ -1552,6 +1807,9 @@ type Query {
   exerciseInstance(where: ExerciseInstanceWhereUniqueInput!): ExerciseInstance
   exerciseInstances(where: ExerciseInstanceWhereInput, orderBy: ExerciseInstanceOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): [ExerciseInstance]!
   exerciseInstancesConnection(where: ExerciseInstanceWhereInput, orderBy: ExerciseInstanceOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): ExerciseInstanceConnection!
+  file(where: FileWhereUniqueInput!): File
+  files(where: FileWhereInput, orderBy: FileOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): [File]!
+  filesConnection(where: FileWhereInput, orderBy: FileOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): FileConnection!
   format(where: FormatWhereUniqueInput!): Format
   formats(where: FormatWhereInput, orderBy: FormatOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): [Format]!
   formatsConnection(where: FormatWhereInput, orderBy: FormatOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): FormatConnection!
@@ -1840,6 +2098,7 @@ type Subscription {
   comment(where: CommentSubscriptionWhereInput): CommentSubscriptionPayload
   exercise(where: ExerciseSubscriptionWhereInput): ExerciseSubscriptionPayload
   exerciseInstance(where: ExerciseInstanceSubscriptionWhereInput): ExerciseInstanceSubscriptionPayload
+  file(where: FileSubscriptionWhereInput): FileSubscriptionPayload
   format(where: FormatSubscriptionWhereInput): FormatSubscriptionPayload
   rating(where: RatingSubscriptionWhereInput): RatingSubscriptionPayload
   track(where: TrackSubscriptionWhereInput): TrackSubscriptionPayload
@@ -2535,6 +2794,11 @@ input UserCreateManyInput {
   connect: [UserWhereUniqueInput!]
 }
 
+input UserCreateOneInput {
+  create: UserCreateInput
+  connect: UserWhereUniqueInput
+}
+
 input UserCreateOneWithoutCommentsInput {
   create: UserCreateWithoutCommentsInput
   connect: UserWhereUniqueInput
@@ -2782,6 +3046,13 @@ input UserUpdateManyWithWhereNestedInput {
   data: UserUpdateManyDataInput!
 }
 
+input UserUpdateOneRequiredInput {
+  create: UserCreateInput
+  update: UserUpdateDataInput
+  upsert: UserUpsertNestedInput
+  connect: UserWhereUniqueInput
+}
+
 input UserUpdateOneRequiredWithoutCommentsInput {
   create: UserCreateWithoutCommentsInput
   update: UserUpdateWithoutCommentsDataInput
@@ -2827,6 +3098,11 @@ input UserUpdateWithWhereUniqueNestedInput {
   data: UserUpdateDataInput!
 }
 
+input UserUpsertNestedInput {
+  update: UserUpdateDataInput!
+  create: UserCreateInput!
+}
+
 input UserUpsertWithoutCommentsInput {
   update: UserUpdateWithoutCommentsDataInput!
   create: UserCreateWithoutCommentsInput!

+ 543 - 11
backend/database/generated/prisma.graphql

@@ -1,5 +1,5 @@
 # source: http://prisma:4466
-# timestamp: Wed Apr 08 2020 18:07:43 GMT+0000 (Coordinated Universal Time)
+# timestamp: Fri Apr 10 2020 18:04:15 GMT+0000 (Coordinated Universal Time)
 
 type AggregateBlock {
   count: Int!
@@ -21,6 +21,10 @@ type AggregateExerciseInstance {
   count: Int!
 }
 
+type AggregateFile {
+  count: Int!
+}
+
 type AggregateFormat {
   count: Int!
 }
@@ -2114,6 +2118,507 @@ input ExerciseWhereUniqueInput {
   id: ID
 }
 
+type File implements Node {
+  id: ID!
+  createdAt: DateTime!
+  updatedAt: DateTime!
+  path: String!
+  mimetype: String!
+  user: User!
+  thumbnail: String
+  filename: String!
+  encoding: String!
+  size: Int!
+  comment: String
+}
+
+"""A connection to a list of items."""
+type FileConnection {
+  """Information to aid in pagination."""
+  pageInfo: PageInfo!
+
+  """A list of edges."""
+  edges: [FileEdge]!
+  aggregate: AggregateFile!
+}
+
+input FileCreateInput {
+  id: ID
+  path: String!
+  mimetype: String!
+  thumbnail: String
+  filename: String!
+  encoding: String!
+  size: Int!
+  comment: String
+  user: UserCreateOneInput!
+}
+
+"""An edge in a connection."""
+type FileEdge {
+  """The item at the end of the edge."""
+  node: File!
+
+  """A cursor for use in pagination."""
+  cursor: String!
+}
+
+enum FileOrderByInput {
+  id_ASC
+  id_DESC
+  createdAt_ASC
+  createdAt_DESC
+  updatedAt_ASC
+  updatedAt_DESC
+  path_ASC
+  path_DESC
+  mimetype_ASC
+  mimetype_DESC
+  thumbnail_ASC
+  thumbnail_DESC
+  filename_ASC
+  filename_DESC
+  encoding_ASC
+  encoding_DESC
+  size_ASC
+  size_DESC
+  comment_ASC
+  comment_DESC
+}
+
+type FilePreviousValues {
+  id: ID!
+  createdAt: DateTime!
+  updatedAt: DateTime!
+  path: String!
+  mimetype: String!
+  thumbnail: String
+  filename: String!
+  encoding: String!
+  size: Int!
+  comment: String
+}
+
+type FileSubscriptionPayload {
+  mutation: MutationType!
+  node: File
+  updatedFields: [String!]
+  previousValues: FilePreviousValues
+}
+
+input FileSubscriptionWhereInput {
+  """Logical AND on all given filters."""
+  AND: [FileSubscriptionWhereInput!]
+
+  """Logical OR on all given filters."""
+  OR: [FileSubscriptionWhereInput!]
+
+  """Logical NOT on all given filters combined by AND."""
+  NOT: [FileSubscriptionWhereInput!]
+
+  """The subscription event gets dispatched when it's listed in mutation_in"""
+  mutation_in: [MutationType!]
+
+  """
+  The subscription event gets only dispatched when one of the updated fields names is included in this list
+  """
+  updatedFields_contains: String
+
+  """
+  The subscription event gets only dispatched when all of the field names included in this list have been updated
+  """
+  updatedFields_contains_every: [String!]
+
+  """
+  The subscription event gets only dispatched when some of the field names included in this list have been updated
+  """
+  updatedFields_contains_some: [String!]
+  node: FileWhereInput
+}
+
+input FileUpdateInput {
+  path: String
+  mimetype: String
+  thumbnail: String
+  filename: String
+  encoding: String
+  size: Int
+  comment: String
+  user: UserUpdateOneRequiredInput
+}
+
+input FileUpdateManyMutationInput {
+  path: String
+  mimetype: String
+  thumbnail: String
+  filename: String
+  encoding: String
+  size: Int
+  comment: String
+}
+
+input FileWhereInput {
+  """Logical AND on all given filters."""
+  AND: [FileWhereInput!]
+
+  """Logical OR on all given filters."""
+  OR: [FileWhereInput!]
+
+  """Logical NOT on all given filters combined by AND."""
+  NOT: [FileWhereInput!]
+  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
+  createdAt: DateTime
+
+  """All values that are not equal to given value."""
+  createdAt_not: DateTime
+
+  """All values that are contained in given list."""
+  createdAt_in: [DateTime!]
+
+  """All values that are not contained in given list."""
+  createdAt_not_in: [DateTime!]
+
+  """All values less than the given value."""
+  createdAt_lt: DateTime
+
+  """All values less than or equal the given value."""
+  createdAt_lte: DateTime
+
+  """All values greater than the given value."""
+  createdAt_gt: DateTime
+
+  """All values greater than or equal the given value."""
+  createdAt_gte: DateTime
+  updatedAt: DateTime
+
+  """All values that are not equal to given value."""
+  updatedAt_not: DateTime
+
+  """All values that are contained in given list."""
+  updatedAt_in: [DateTime!]
+
+  """All values that are not contained in given list."""
+  updatedAt_not_in: [DateTime!]
+
+  """All values less than the given value."""
+  updatedAt_lt: DateTime
+
+  """All values less than or equal the given value."""
+  updatedAt_lte: DateTime
+
+  """All values greater than the given value."""
+  updatedAt_gt: DateTime
+
+  """All values greater than or equal the given value."""
+  updatedAt_gte: DateTime
+  path: String
+
+  """All values that are not equal to given value."""
+  path_not: String
+
+  """All values that are contained in given list."""
+  path_in: [String!]
+
+  """All values that are not contained in given list."""
+  path_not_in: [String!]
+
+  """All values less than the given value."""
+  path_lt: String
+
+  """All values less than or equal the given value."""
+  path_lte: String
+
+  """All values greater than the given value."""
+  path_gt: String
+
+  """All values greater than or equal the given value."""
+  path_gte: String
+
+  """All values containing the given string."""
+  path_contains: String
+
+  """All values not containing the given string."""
+  path_not_contains: String
+
+  """All values starting with the given string."""
+  path_starts_with: String
+
+  """All values not starting with the given string."""
+  path_not_starts_with: String
+
+  """All values ending with the given string."""
+  path_ends_with: String
+
+  """All values not ending with the given string."""
+  path_not_ends_with: String
+  mimetype: String
+
+  """All values that are not equal to given value."""
+  mimetype_not: String
+
+  """All values that are contained in given list."""
+  mimetype_in: [String!]
+
+  """All values that are not contained in given list."""
+  mimetype_not_in: [String!]
+
+  """All values less than the given value."""
+  mimetype_lt: String
+
+  """All values less than or equal the given value."""
+  mimetype_lte: String
+
+  """All values greater than the given value."""
+  mimetype_gt: String
+
+  """All values greater than or equal the given value."""
+  mimetype_gte: String
+
+  """All values containing the given string."""
+  mimetype_contains: String
+
+  """All values not containing the given string."""
+  mimetype_not_contains: String
+
+  """All values starting with the given string."""
+  mimetype_starts_with: String
+
+  """All values not starting with the given string."""
+  mimetype_not_starts_with: String
+
+  """All values ending with the given string."""
+  mimetype_ends_with: String
+
+  """All values not ending with the given string."""
+  mimetype_not_ends_with: String
+  thumbnail: String
+
+  """All values that are not equal to given value."""
+  thumbnail_not: String
+
+  """All values that are contained in given list."""
+  thumbnail_in: [String!]
+
+  """All values that are not contained in given list."""
+  thumbnail_not_in: [String!]
+
+  """All values less than the given value."""
+  thumbnail_lt: String
+
+  """All values less than or equal the given value."""
+  thumbnail_lte: String
+
+  """All values greater than the given value."""
+  thumbnail_gt: String
+
+  """All values greater than or equal the given value."""
+  thumbnail_gte: String
+
+  """All values containing the given string."""
+  thumbnail_contains: String
+
+  """All values not containing the given string."""
+  thumbnail_not_contains: String
+
+  """All values starting with the given string."""
+  thumbnail_starts_with: String
+
+  """All values not starting with the given string."""
+  thumbnail_not_starts_with: String
+
+  """All values ending with the given string."""
+  thumbnail_ends_with: String
+
+  """All values not ending with the given string."""
+  thumbnail_not_ends_with: String
+  filename: String
+
+  """All values that are not equal to given value."""
+  filename_not: String
+
+  """All values that are contained in given list."""
+  filename_in: [String!]
+
+  """All values that are not contained in given list."""
+  filename_not_in: [String!]
+
+  """All values less than the given value."""
+  filename_lt: String
+
+  """All values less than or equal the given value."""
+  filename_lte: String
+
+  """All values greater than the given value."""
+  filename_gt: String
+
+  """All values greater than or equal the given value."""
+  filename_gte: String
+
+  """All values containing the given string."""
+  filename_contains: String
+
+  """All values not containing the given string."""
+  filename_not_contains: String
+
+  """All values starting with the given string."""
+  filename_starts_with: String
+
+  """All values not starting with the given string."""
+  filename_not_starts_with: String
+
+  """All values ending with the given string."""
+  filename_ends_with: String
+
+  """All values not ending with the given string."""
+  filename_not_ends_with: String
+  encoding: String
+
+  """All values that are not equal to given value."""
+  encoding_not: String
+
+  """All values that are contained in given list."""
+  encoding_in: [String!]
+
+  """All values that are not contained in given list."""
+  encoding_not_in: [String!]
+
+  """All values less than the given value."""
+  encoding_lt: String
+
+  """All values less than or equal the given value."""
+  encoding_lte: String
+
+  """All values greater than the given value."""
+  encoding_gt: String
+
+  """All values greater than or equal the given value."""
+  encoding_gte: String
+
+  """All values containing the given string."""
+  encoding_contains: String
+
+  """All values not containing the given string."""
+  encoding_not_contains: String
+
+  """All values starting with the given string."""
+  encoding_starts_with: String
+
+  """All values not starting with the given string."""
+  encoding_not_starts_with: String
+
+  """All values ending with the given string."""
+  encoding_ends_with: String
+
+  """All values not ending with the given string."""
+  encoding_not_ends_with: String
+  size: Int
+
+  """All values that are not equal to given value."""
+  size_not: Int
+
+  """All values that are contained in given list."""
+  size_in: [Int!]
+
+  """All values that are not contained in given list."""
+  size_not_in: [Int!]
+
+  """All values less than the given value."""
+  size_lt: Int
+
+  """All values less than or equal the given value."""
+  size_lte: Int
+
+  """All values greater than the given value."""
+  size_gt: Int
+
+  """All values greater than or equal the given value."""
+  size_gte: Int
+  comment: String
+
+  """All values that are not equal to given value."""
+  comment_not: String
+
+  """All values that are contained in given list."""
+  comment_in: [String!]
+
+  """All values that are not contained in given list."""
+  comment_not_in: [String!]
+
+  """All values less than the given value."""
+  comment_lt: String
+
+  """All values less than or equal the given value."""
+  comment_lte: String
+
+  """All values greater than the given value."""
+  comment_gt: String
+
+  """All values greater than or equal the given value."""
+  comment_gte: String
+
+  """All values containing the given string."""
+  comment_contains: String
+
+  """All values not containing the given string."""
+  comment_not_contains: String
+
+  """All values starting with the given string."""
+  comment_starts_with: String
+
+  """All values not starting with the given string."""
+  comment_not_starts_with: String
+
+  """All values ending with the given string."""
+  comment_ends_with: String
+
+  """All values not ending with the given string."""
+  comment_not_ends_with: String
+  user: UserWhereInput
+}
+
+input FileWhereUniqueInput {
+  id: ID
+}
+
 type Format implements Node {
   id: ID!
   name: String!
@@ -2371,67 +2876,73 @@ Long can represent values between -(2^63) and 2^63 - 1.
 scalar Long
 
 type Mutation {
+  createFile(data: FileCreateInput!): File!
   createTraining(data: TrainingCreateInput!): Training!
   createBlock(data: BlockCreateInput!): Block!
   createBlockInstance(data: BlockInstanceCreateInput!): BlockInstance!
   createComment(data: CommentCreateInput!): Comment!
-  createUser(data: UserCreateInput!): User!
   createTrainingType(data: TrainingTypeCreateInput!): TrainingType!
+  createUser(data: UserCreateInput!): User!
   createTrack(data: TrackCreateInput!): Track!
   createExercise(data: ExerciseCreateInput!): Exercise!
   createFormat(data: FormatCreateInput!): Format!
   createExerciseInstance(data: ExerciseInstanceCreateInput!): ExerciseInstance!
   createRating(data: RatingCreateInput!): Rating!
+  updateFile(data: FileUpdateInput!, where: FileWhereUniqueInput!): File
   updateTraining(data: TrainingUpdateInput!, where: TrainingWhereUniqueInput!): Training
   updateBlock(data: BlockUpdateInput!, where: BlockWhereUniqueInput!): Block
   updateBlockInstance(data: BlockInstanceUpdateInput!, where: BlockInstanceWhereUniqueInput!): BlockInstance
   updateComment(data: CommentUpdateInput!, where: CommentWhereUniqueInput!): Comment
-  updateUser(data: UserUpdateInput!, where: UserWhereUniqueInput!): User
   updateTrainingType(data: TrainingTypeUpdateInput!, where: TrainingTypeWhereUniqueInput!): TrainingType
+  updateUser(data: UserUpdateInput!, where: UserWhereUniqueInput!): User
   updateTrack(data: TrackUpdateInput!, where: TrackWhereUniqueInput!): Track
   updateExercise(data: ExerciseUpdateInput!, where: ExerciseWhereUniqueInput!): Exercise
   updateFormat(data: FormatUpdateInput!, where: FormatWhereUniqueInput!): Format
   updateExerciseInstance(data: ExerciseInstanceUpdateInput!, where: ExerciseInstanceWhereUniqueInput!): ExerciseInstance
   updateRating(data: RatingUpdateInput!, where: RatingWhereUniqueInput!): Rating
+  deleteFile(where: FileWhereUniqueInput!): File
   deleteTraining(where: TrainingWhereUniqueInput!): Training
   deleteBlock(where: BlockWhereUniqueInput!): Block
   deleteBlockInstance(where: BlockInstanceWhereUniqueInput!): BlockInstance
   deleteComment(where: CommentWhereUniqueInput!): Comment
-  deleteUser(where: UserWhereUniqueInput!): User
   deleteTrainingType(where: TrainingTypeWhereUniqueInput!): TrainingType
+  deleteUser(where: UserWhereUniqueInput!): User
   deleteTrack(where: TrackWhereUniqueInput!): Track
   deleteExercise(where: ExerciseWhereUniqueInput!): Exercise
   deleteFormat(where: FormatWhereUniqueInput!): Format
   deleteExerciseInstance(where: ExerciseInstanceWhereUniqueInput!): ExerciseInstance
   deleteRating(where: RatingWhereUniqueInput!): Rating
+  upsertFile(where: FileWhereUniqueInput!, create: FileCreateInput!, update: FileUpdateInput!): File!
   upsertTraining(where: TrainingWhereUniqueInput!, create: TrainingCreateInput!, update: TrainingUpdateInput!): Training!
   upsertBlock(where: BlockWhereUniqueInput!, create: BlockCreateInput!, update: BlockUpdateInput!): Block!
   upsertBlockInstance(where: BlockInstanceWhereUniqueInput!, create: BlockInstanceCreateInput!, update: BlockInstanceUpdateInput!): BlockInstance!
   upsertComment(where: CommentWhereUniqueInput!, create: CommentCreateInput!, update: CommentUpdateInput!): Comment!
-  upsertUser(where: UserWhereUniqueInput!, create: UserCreateInput!, update: UserUpdateInput!): User!
   upsertTrainingType(where: TrainingTypeWhereUniqueInput!, create: TrainingTypeCreateInput!, update: TrainingTypeUpdateInput!): TrainingType!
+  upsertUser(where: UserWhereUniqueInput!, create: UserCreateInput!, update: UserUpdateInput!): User!
   upsertTrack(where: TrackWhereUniqueInput!, create: TrackCreateInput!, update: TrackUpdateInput!): Track!
   upsertExercise(where: ExerciseWhereUniqueInput!, create: ExerciseCreateInput!, update: ExerciseUpdateInput!): Exercise!
   upsertFormat(where: FormatWhereUniqueInput!, create: FormatCreateInput!, update: FormatUpdateInput!): Format!
   upsertExerciseInstance(where: ExerciseInstanceWhereUniqueInput!, create: ExerciseInstanceCreateInput!, update: ExerciseInstanceUpdateInput!): ExerciseInstance!
   upsertRating(where: RatingWhereUniqueInput!, create: RatingCreateInput!, update: RatingUpdateInput!): Rating!
+  updateManyFiles(data: FileUpdateManyMutationInput!, where: FileWhereInput): BatchPayload!
   updateManyTrainings(data: TrainingUpdateManyMutationInput!, where: TrainingWhereInput): BatchPayload!
   updateManyBlocks(data: BlockUpdateManyMutationInput!, where: BlockWhereInput): BatchPayload!
   updateManyBlockInstances(data: BlockInstanceUpdateManyMutationInput!, where: BlockInstanceWhereInput): BatchPayload!
   updateManyComments(data: CommentUpdateManyMutationInput!, where: CommentWhereInput): BatchPayload!
-  updateManyUsers(data: UserUpdateManyMutationInput!, where: UserWhereInput): BatchPayload!
   updateManyTrainingTypes(data: TrainingTypeUpdateManyMutationInput!, where: TrainingTypeWhereInput): BatchPayload!
+  updateManyUsers(data: UserUpdateManyMutationInput!, where: UserWhereInput): BatchPayload!
   updateManyTracks(data: TrackUpdateManyMutationInput!, where: TrackWhereInput): BatchPayload!
   updateManyExercises(data: ExerciseUpdateManyMutationInput!, where: ExerciseWhereInput): BatchPayload!
   updateManyFormats(data: FormatUpdateManyMutationInput!, where: FormatWhereInput): BatchPayload!
   updateManyExerciseInstances(data: ExerciseInstanceUpdateManyMutationInput!, where: ExerciseInstanceWhereInput): BatchPayload!
   updateManyRatings(data: RatingUpdateManyMutationInput!, where: RatingWhereInput): BatchPayload!
+  deleteManyFiles(where: FileWhereInput): BatchPayload!
   deleteManyTrainings(where: TrainingWhereInput): BatchPayload!
   deleteManyBlocks(where: BlockWhereInput): BatchPayload!
   deleteManyBlockInstances(where: BlockInstanceWhereInput): BatchPayload!
   deleteManyComments(where: CommentWhereInput): BatchPayload!
-  deleteManyUsers(where: UserWhereInput): BatchPayload!
   deleteManyTrainingTypes(where: TrainingTypeWhereInput): BatchPayload!
+  deleteManyUsers(where: UserWhereInput): BatchPayload!
   deleteManyTracks(where: TrackWhereInput): BatchPayload!
   deleteManyExercises(where: ExerciseWhereInput): BatchPayload!
   deleteManyFormats(where: FormatWhereInput): BatchPayload!
@@ -2472,34 +2983,37 @@ enum Permission {
 }
 
 type Query {
+  files(where: FileWhereInput, orderBy: FileOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): [File]!
   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]!
   comments(where: CommentWhereInput, orderBy: CommentOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): [Comment]!
-  users(where: UserWhereInput, orderBy: UserOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): [User]!
   trainingTypes(where: TrainingTypeWhereInput, orderBy: TrainingTypeOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): [TrainingType]!
+  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]!
   exercises(where: ExerciseWhereInput, orderBy: ExerciseOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): [Exercise]!
   formats(where: FormatWhereInput, orderBy: FormatOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): [Format]!
   exerciseInstances(where: ExerciseInstanceWhereInput, orderBy: ExerciseInstanceOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): [ExerciseInstance]!
   ratings(where: RatingWhereInput, orderBy: RatingOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): [Rating]!
+  file(where: FileWhereUniqueInput!): File
   training(where: TrainingWhereUniqueInput!): Training
   block(where: BlockWhereUniqueInput!): Block
   blockInstance(where: BlockInstanceWhereUniqueInput!): BlockInstance
   comment(where: CommentWhereUniqueInput!): Comment
-  user(where: UserWhereUniqueInput!): User
   trainingType(where: TrainingTypeWhereUniqueInput!): TrainingType
+  user(where: UserWhereUniqueInput!): User
   track(where: TrackWhereUniqueInput!): Track
   exercise(where: ExerciseWhereUniqueInput!): Exercise
   format(where: FormatWhereUniqueInput!): Format
   exerciseInstance(where: ExerciseInstanceWhereUniqueInput!): ExerciseInstance
   rating(where: RatingWhereUniqueInput!): Rating
+  filesConnection(where: FileWhereInput, orderBy: FileOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): FileConnection!
   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!
   commentsConnection(where: CommentWhereInput, orderBy: CommentOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): CommentConnection!
-  usersConnection(where: UserWhereInput, orderBy: UserOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): UserConnection!
   trainingTypesConnection(where: TrainingTypeWhereInput, orderBy: TrainingTypeOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): TrainingTypeConnection!
+  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!
   exercisesConnection(where: ExerciseWhereInput, orderBy: ExerciseOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): ExerciseConnection!
   formatsConnection(where: FormatWhereInput, orderBy: FormatOrderByInput, skip: Int, after: String, before: String, first: Int, last: Int): FormatConnection!
@@ -2972,12 +3486,13 @@ input RatingWhereUniqueInput {
 }
 
 type Subscription {
+  file(where: FileSubscriptionWhereInput): FileSubscriptionPayload
   training(where: TrainingSubscriptionWhereInput): TrainingSubscriptionPayload
   block(where: BlockSubscriptionWhereInput): BlockSubscriptionPayload
   blockInstance(where: BlockInstanceSubscriptionWhereInput): BlockInstanceSubscriptionPayload
   comment(where: CommentSubscriptionWhereInput): CommentSubscriptionPayload
-  user(where: UserSubscriptionWhereInput): UserSubscriptionPayload
   trainingType(where: TrainingTypeSubscriptionWhereInput): TrainingTypeSubscriptionPayload
+  user(where: UserSubscriptionWhereInput): UserSubscriptionPayload
   track(where: TrackSubscriptionWhereInput): TrackSubscriptionPayload
   exercise(where: ExerciseSubscriptionWhereInput): ExerciseSubscriptionPayload
   format(where: FormatSubscriptionWhereInput): FormatSubscriptionPayload
@@ -4213,6 +4728,11 @@ input UserCreateManyInput {
   connect: [UserWhereUniqueInput!]
 }
 
+input UserCreateOneInput {
+  create: UserCreateInput
+  connect: UserWhereUniqueInput
+}
+
 input UserCreateOneWithoutCommentsInput {
   create: UserCreateWithoutCommentsInput
   connect: UserWhereUniqueInput
@@ -4646,6 +5166,13 @@ input UserUpdateManyWithWhereNestedInput {
   data: UserUpdateManyDataInput!
 }
 
+input UserUpdateOneRequiredInput {
+  create: UserCreateInput
+  connect: UserWhereUniqueInput
+  update: UserUpdateDataInput
+  upsert: UserUpsertNestedInput
+}
+
 input UserUpdateOneRequiredWithoutCommentsInput {
   create: UserCreateWithoutCommentsInput
   connect: UserWhereUniqueInput
@@ -4691,6 +5218,11 @@ input UserUpdateWithWhereUniqueNestedInput {
   data: UserUpdateDataInput!
 }
 
+input UserUpsertNestedInput {
+  update: UserUpdateDataInput!
+  create: UserCreateInput!
+}
+
 input UserUpsertWithoutCommentsInput {
   update: UserUpdateWithoutCommentsDataInput!
   create: UserCreateWithoutCommentsInput!

+ 14 - 0
backend/datamodel.prisma

@@ -17,6 +17,20 @@ enum Permission {
     INSTRUCTOR
 }
 
+type File {
+    id: ID! @id
+    createdAt: DateTime! @createdAt
+    updatedAt: DateTime! @updatedAt
+    path: String!
+    mimetype: String!
+    user: User!
+    thumbnail: String
+    filename: String!
+    encoding: String!
+    size: Int!
+    comment: String
+}
+
 type Training {
     id: ID! @id
     title: String!

+ 4 - 6
backend/index.ts

@@ -7,14 +7,14 @@ import { merge } from 'lodash'
 import { importSchema } from 'graphql-import'
 import { db, populateUser } from './src/db'
 import { authenticate } from './src/user/authenticate'
-//import file from './src/file'
+import file from './src/file'
 import user from './src/user'
 import history from './src/history'
 //import google from './src/google'
 import training from './src/training'
 
 const resolvers = merge(
-  //file.resolvers,
+  file.resolvers,
   training.resolvers,
   user.resolvers,
   history.resolvers
@@ -48,12 +48,10 @@ const server = new ApolloServer({
   debug: false,
   engine: { debugPrintReports: false }
 } as ApolloServerExpressConfig)
-server.applyMiddleware({ app, cors: corsOptions })
+server.applyMiddleware({ app, cors: corsOptions, path: '/graphql' })
 
 app.listen({ port: process.env.PORT }, () => {
-  console.log(
-    `Server ready at http://localhost:${process.env.PORT}/${server.graphqlPath} 🚀!`
-  )
+  console.log(`Server ready 🚀!`)
 })
 
 export default app

+ 361 - 9
backend/package-lock.json

@@ -1941,6 +1941,14 @@
         "@types/mime": "*"
       }
     },
+    "@types/sharp": {
+      "version": "0.24.0",
+      "resolved": "https://registry.npmjs.org/@types/sharp/-/sharp-0.24.0.tgz",
+      "integrity": "sha512-+0WeyJajTSoIacBzonsq856whNJC+cN9FNEs0yZ6hFq/V1CZmlqM8vBRy7TKZunH+gIO7SwDCzgXYWRRbzqfDA==",
+      "requires": {
+        "@types/node": "*"
+      }
+    },
     "@types/stack-utils": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-1.0.1.tgz",
@@ -2735,6 +2743,11 @@
         "tslib": "^1.9.3"
       }
     },
+    "aproba": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz",
+      "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw=="
+    },
     "arch": {
       "version": "2.1.1",
       "resolved": "https://registry.npmjs.org/arch/-/arch-2.1.1.tgz",
@@ -2778,6 +2791,15 @@
         }
       }
     },
+    "are-we-there-yet": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz",
+      "integrity": "sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w==",
+      "requires": {
+        "delegates": "^1.0.0",
+        "readable-stream": "^2.0.6"
+      }
+    },
     "argparse": {
       "version": "1.0.10",
       "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
@@ -3620,8 +3642,7 @@
     "chownr": {
       "version": "1.1.3",
       "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.3.tgz",
-      "integrity": "sha512-i70fVHhmV3DtTl6nqvZOnIjbY0Pe4kAUjwHj8z0zAdgBtYrJyYwLKCCuRBQ5ppkyL0AkN7HKRnETdmdp1zqNXw==",
-      "dev": true
+      "integrity": "sha512-i70fVHhmV3DtTl6nqvZOnIjbY0Pe4kAUjwHj8z0zAdgBtYrJyYwLKCCuRBQ5ppkyL0AkN7HKRnETdmdp1zqNXw=="
     },
     "ci-info": {
       "version": "2.0.0",
@@ -3769,6 +3790,15 @@
         "object-visit": "^1.0.0"
       }
     },
+    "color": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/color/-/color-3.1.2.tgz",
+      "integrity": "sha512-vXTJhHebByxZn3lDvDJYw4lR5+uB3vuoHsuYA5AKuxRVn5wzzIfQKGLBmgdVRHKTJYeK5rvJcHnrd0Li49CFpg==",
+      "requires": {
+        "color-convert": "^1.9.1",
+        "color-string": "^1.5.2"
+      }
+    },
     "color-convert": {
       "version": "1.9.3",
       "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
@@ -3782,6 +3812,15 @@
       "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
       "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU="
     },
+    "color-string": {
+      "version": "1.5.3",
+      "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.5.3.tgz",
+      "integrity": "sha512-dC2C5qeWoYkxki5UAXapdjqO672AM4vZuPGRQfO8b5HKuKGBbKWpITyDYN7TOFKvRW7kOgAn3746clDBMDJyQw==",
+      "requires": {
+        "color-name": "^1.0.0",
+        "simple-swizzle": "^0.2.2"
+      }
+    },
     "colors": {
       "version": "1.0.3",
       "resolved": "https://registry.npmjs.org/colors/-/colors-1.0.3.tgz",
@@ -4083,6 +4122,11 @@
         "xdg-basedir": "^3.0.0"
       }
     },
+    "console-control-strings": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
+      "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4="
+    },
     "constant-case": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/constant-case/-/constant-case-2.0.0.tgz",
@@ -4588,6 +4632,11 @@
       "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
       "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk="
     },
+    "delegates": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
+      "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o="
+    },
     "depd": {
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz",
@@ -4603,6 +4652,11 @@
       "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz",
       "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA="
     },
+    "detect-libc": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
+      "integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups="
+    },
     "detect-newline": {
       "version": "3.1.0",
       "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz",
@@ -5348,6 +5402,11 @@
         }
       }
     },
+    "expand-template": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
+      "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="
+    },
     "expand-tilde": {
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz",
@@ -5816,7 +5875,6 @@
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.0.0.tgz",
       "integrity": "sha512-40Qz+LFXmd9tzYVnnBmZvFfvAADfUA14TXPK1s7IfElJTIZ97rA8w4Kin7Wt5JBrC3ShnnFJO/5vPjPEeJIq9A==",
-      "dev": true,
       "requires": {
         "minipass": "^3.0.0"
       }
@@ -6384,6 +6442,54 @@
       "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz",
       "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc="
     },
+    "gauge": {
+      "version": "2.7.4",
+      "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz",
+      "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=",
+      "requires": {
+        "aproba": "^1.0.3",
+        "console-control-strings": "^1.0.0",
+        "has-unicode": "^2.0.0",
+        "object-assign": "^4.1.0",
+        "signal-exit": "^3.0.0",
+        "string-width": "^1.0.1",
+        "strip-ansi": "^3.0.1",
+        "wide-align": "^1.1.0"
+      },
+      "dependencies": {
+        "ansi-regex": {
+          "version": "2.1.1",
+          "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
+          "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8="
+        },
+        "is-fullwidth-code-point": {
+          "version": "1.0.0",
+          "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz",
+          "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=",
+          "requires": {
+            "number-is-nan": "^1.0.0"
+          }
+        },
+        "string-width": {
+          "version": "1.0.2",
+          "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz",
+          "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=",
+          "requires": {
+            "code-point-at": "^1.0.0",
+            "is-fullwidth-code-point": "^1.0.0",
+            "strip-ansi": "^3.0.0"
+          }
+        },
+        "strip-ansi": {
+          "version": "3.0.1",
+          "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+          "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
+          "requires": {
+            "ansi-regex": "^2.0.0"
+          }
+        }
+      }
+    },
     "gensync": {
       "version": "1.0.0-beta.1",
       "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.1.tgz",
@@ -6418,6 +6524,11 @@
         "assert-plus": "^1.0.0"
       }
     },
+    "github-from-package": {
+      "version": "0.0.0",
+      "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
+      "integrity": "sha1-l/tdlr/eiXMxPyDoKI75oWf6ZM4="
+    },
     "glob": {
       "version": "7.1.5",
       "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.5.tgz",
@@ -7030,6 +7141,11 @@
       "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.0.tgz",
       "integrity": "sha1-uhqPGvKg/DllD1yFA2dwQSIGO0Q="
     },
+    "has-unicode": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
+      "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk="
+    },
     "has-value": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/has-value/-/has-value-1.0.0.tgz",
@@ -10396,7 +10512,6 @@
       "version": "3.1.1",
       "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.1.1.tgz",
       "integrity": "sha512-UFqVihv6PQgwj8/yTGvl9kPz7xIAY+R5z6XYjRInD3Gk3qx6QGSD6zEcpeG4Dy/lQnv1J6zv8ejV90hyYIKf3w==",
-      "dev": true,
       "requires": {
         "yallist": "^4.0.0"
       },
@@ -10404,8 +10519,7 @@
         "yallist": {
           "version": "4.0.0",
           "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
-          "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
-          "dev": true
+          "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
         }
       }
     },
@@ -10413,7 +10527,6 @@
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.0.tgz",
       "integrity": "sha512-EzTZN/fjSvifSX0SlqUERCN39o6T40AMarPbv0MrarSFtIITCBh7bi+dU8nxGFHuqs9jdIAeoYoKuQAAASsPPA==",
-      "dev": true,
       "requires": {
         "minipass": "^3.0.0",
         "yallist": "^4.0.0"
@@ -10422,8 +10535,7 @@
         "yallist": {
           "version": "4.0.0",
           "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
-          "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
-          "dev": true
+          "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
         }
       }
     },
@@ -10454,6 +10566,11 @@
         "minimist": "0.0.8"
       }
     },
+    "mkdirp-classic": {
+      "version": "0.5.2",
+      "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.2.tgz",
+      "integrity": "sha512-ejdnDQcR75gwknmMw/tx02AuRs8jCtqFoFqDZMjiNxsu85sRIJVXDKHuLYvUUPRBUtV2FpSZa9bL1BUa3BdR2g=="
+    },
     "mongodb": {
       "version": "3.3.3",
       "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-3.3.3.tgz",
@@ -10530,6 +10647,11 @@
         "to-regex": "^3.0.1"
       }
     },
+    "napi-build-utils": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz",
+      "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg=="
+    },
     "natural-compare": {
       "version": "1.4.0",
       "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@@ -10553,6 +10675,26 @@
         "lower-case": "^1.1.1"
       }
     },
+    "node-abi": {
+      "version": "2.15.0",
+      "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-2.15.0.tgz",
+      "integrity": "sha512-FeLpTS0F39U7hHZU1srAK4Vx+5AHNVOTP+hxBNQknR/54laTHSFIJkDWDqiquY1LeLUgTfPN7sLPhMubx0PLAg==",
+      "requires": {
+        "semver": "^5.4.1"
+      },
+      "dependencies": {
+        "semver": {
+          "version": "5.7.1",
+          "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
+          "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ=="
+        }
+      }
+    },
+    "node-addon-api": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-2.0.0.tgz",
+      "integrity": "sha512-ASCL5U13as7HhOExbT6OlWJJUV/lLzL2voOSP1UVehpRD8FbSrSDjfScK/KwAvVTI5AS6r4VwbOMlIqtvRidnA=="
+    },
     "node-emoji": {
       "version": "1.10.0",
       "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.10.0.tgz",
@@ -10658,6 +10800,11 @@
         }
       }
     },
+    "noop-logger": {
+      "version": "0.1.1",
+      "resolved": "https://registry.npmjs.org/noop-logger/-/noop-logger-0.1.1.tgz",
+      "integrity": "sha1-lKKxYzxPExdVMAfYlm/Q6EG2pMI="
+    },
     "nopt": {
       "version": "1.0.10",
       "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz",
@@ -10763,6 +10910,17 @@
         "which": "^1.2.10"
       }
     },
+    "npmlog": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz",
+      "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==",
+      "requires": {
+        "are-we-there-yet": "~1.1.2",
+        "console-control-strings": "~1.1.0",
+        "gauge": "~2.7.3",
+        "set-blocking": "~2.0.0"
+      }
+    },
     "number-is-nan": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz",
@@ -11618,6 +11776,35 @@
         "xtend": "^4.0.0"
       }
     },
+    "prebuild-install": {
+      "version": "5.3.3",
+      "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-5.3.3.tgz",
+      "integrity": "sha512-GV+nsUXuPW2p8Zy7SarF/2W/oiK8bFQgJcncoJ0d7kRpekEA0ftChjfEaF9/Y+QJEc/wFR7RAEa8lYByuUIe2g==",
+      "requires": {
+        "detect-libc": "^1.0.3",
+        "expand-template": "^2.0.3",
+        "github-from-package": "0.0.0",
+        "minimist": "^1.2.0",
+        "mkdirp": "^0.5.1",
+        "napi-build-utils": "^1.0.1",
+        "node-abi": "^2.7.0",
+        "noop-logger": "^0.1.1",
+        "npmlog": "^4.0.1",
+        "pump": "^3.0.0",
+        "rc": "^1.2.7",
+        "simple-get": "^3.0.3",
+        "tar-fs": "^2.0.0",
+        "tunnel-agent": "^0.6.0",
+        "which-pm-runs": "^1.0.0"
+      },
+      "dependencies": {
+        "minimist": {
+          "version": "1.2.5",
+          "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
+          "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw=="
+        }
+      }
+    },
     "prelude-ls": {
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz",
@@ -13149,6 +13336,52 @@
         "safe-buffer": "^5.0.1"
       }
     },
+    "sharp": {
+      "version": "0.25.2",
+      "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.25.2.tgz",
+      "integrity": "sha512-l1GN0kFNtJr3U9i9pt7a+vo2Ij0xv4tTKDIPx8W6G9WELhPwrMyZZJKAAQNBSI785XB4uZfS5Wpz8C9jWV4AFQ==",
+      "requires": {
+        "color": "^3.1.2",
+        "detect-libc": "^1.0.3",
+        "node-addon-api": "^2.0.0",
+        "npmlog": "^4.1.2",
+        "prebuild-install": "^5.3.3",
+        "semver": "^7.1.3",
+        "simple-get": "^3.1.0",
+        "tar": "^6.0.1",
+        "tunnel-agent": "^0.6.0"
+      },
+      "dependencies": {
+        "mkdirp": {
+          "version": "1.0.4",
+          "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
+          "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="
+        },
+        "semver": {
+          "version": "7.2.2",
+          "resolved": "https://registry.npmjs.org/semver/-/semver-7.2.2.tgz",
+          "integrity": "sha512-Zo84u6o2PebMSK3zjJ6Zp5wi8VnQZnEaCP13Ul/lt1ANsLACxnJxq4EEm1PY94/por1Hm9+7xpIswdS5AkieMA=="
+        },
+        "tar": {
+          "version": "6.0.1",
+          "resolved": "https://registry.npmjs.org/tar/-/tar-6.0.1.tgz",
+          "integrity": "sha512-bKhKrrz2FJJj5s7wynxy/fyxpE0CmCjmOQ1KV4KkgXFWOgoIT/NbTMnB1n+LFNrNk0SSBVGGxcK5AGsyC+pW5Q==",
+          "requires": {
+            "chownr": "^1.1.3",
+            "fs-minipass": "^2.0.0",
+            "minipass": "^3.0.0",
+            "minizlib": "^2.1.0",
+            "mkdirp": "^1.0.3",
+            "yallist": "^4.0.0"
+          }
+        },
+        "yallist": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+          "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
+        }
+      }
+    },
     "shebang-command": {
       "version": "1.2.0",
       "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz",
@@ -13179,6 +13412,11 @@
       "resolved": "https://registry.npmjs.org/sillyname/-/sillyname-0.1.0.tgz",
       "integrity": "sha1-z9mIWOJJhnE0d3Xv47tRQfRsh9Y="
     },
+    "simple-concat": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.0.tgz",
+      "integrity": "sha1-c0TLuLbib7J9ZrL8hvn21Zl1IcY="
+    },
     "simple-errors": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/simple-errors/-/simple-errors-1.0.1.tgz",
@@ -13187,6 +13425,46 @@
         "errno": "^0.1.1"
       }
     },
+    "simple-get": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-3.1.0.tgz",
+      "integrity": "sha512-bCR6cP+aTdScaQCnQKbPKtJOKDp/hj9EDLJo3Nw4y1QksqaovlW/bnptB6/c1e+qmNIDHRK+oXFDdEqBT8WzUA==",
+      "requires": {
+        "decompress-response": "^4.2.0",
+        "once": "^1.3.1",
+        "simple-concat": "^1.0.0"
+      },
+      "dependencies": {
+        "decompress-response": {
+          "version": "4.2.1",
+          "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-4.2.1.tgz",
+          "integrity": "sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==",
+          "requires": {
+            "mimic-response": "^2.0.0"
+          }
+        },
+        "mimic-response": {
+          "version": "2.1.0",
+          "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz",
+          "integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA=="
+        }
+      }
+    },
+    "simple-swizzle": {
+      "version": "0.2.2",
+      "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz",
+      "integrity": "sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo=",
+      "requires": {
+        "is-arrayish": "^0.3.1"
+      },
+      "dependencies": {
+        "is-arrayish": {
+          "version": "0.3.2",
+          "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz",
+          "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="
+        }
+      }
+    },
     "sisteransi": {
       "version": "1.0.5",
       "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
@@ -13886,6 +14164,67 @@
         }
       }
     },
+    "tar-fs": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.0.1.tgz",
+      "integrity": "sha512-6tzWDMeroL87uF/+lin46k+Q+46rAJ0SyPGz7OW7wTgblI273hsBqk2C1j0/xNadNLKDTUL9BukSjB7cwgmlPA==",
+      "requires": {
+        "chownr": "^1.1.1",
+        "mkdirp-classic": "^0.5.2",
+        "pump": "^3.0.0",
+        "tar-stream": "^2.0.0"
+      },
+      "dependencies": {
+        "bl": {
+          "version": "4.0.2",
+          "resolved": "https://registry.npmjs.org/bl/-/bl-4.0.2.tgz",
+          "integrity": "sha512-j4OH8f6Qg2bGuWfRiltT2HYGx0e1QcBTrK9KAHNMwMZdQnDZFk0ZSYIpADjYCB3U12nicC5tVJwSIhwOWjb4RQ==",
+          "requires": {
+            "buffer": "^5.5.0",
+            "inherits": "^2.0.4",
+            "readable-stream": "^3.4.0"
+          },
+          "dependencies": {
+            "inherits": {
+              "version": "2.0.4",
+              "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+              "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
+            }
+          }
+        },
+        "buffer": {
+          "version": "5.5.0",
+          "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.5.0.tgz",
+          "integrity": "sha512-9FTEDjLjwoAkEwyMGDjYJQN2gfRgOKBKRfiglhvibGbpeeU/pQn1bJxQqm32OD/AIeEuHxU9roxXxg34Byp/Ww==",
+          "requires": {
+            "base64-js": "^1.0.2",
+            "ieee754": "^1.1.4"
+          }
+        },
+        "readable-stream": {
+          "version": "3.6.0",
+          "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
+          "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==",
+          "requires": {
+            "inherits": "^2.0.3",
+            "string_decoder": "^1.1.1",
+            "util-deprecate": "^1.0.1"
+          }
+        },
+        "tar-stream": {
+          "version": "2.1.2",
+          "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.1.2.tgz",
+          "integrity": "sha512-UaF6FoJ32WqALZGOIAApXx+OdxhekNMChu6axLJR85zMMjXKWFGjbIRe+J6P4UnRGg9rAwWvbTT0oI7hD/Un7Q==",
+          "requires": {
+            "bl": "^4.0.1",
+            "end-of-stream": "^1.4.1",
+            "fs-constants": "^1.0.0",
+            "inherits": "^2.0.3",
+            "readable-stream": "^3.1.1"
+          }
+        }
+      }
+    },
     "tar-stream": {
       "version": "1.6.2",
       "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-1.6.2.tgz",
@@ -14859,6 +15198,19 @@
       "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz",
       "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho="
     },
+    "which-pm-runs": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/which-pm-runs/-/which-pm-runs-1.0.0.tgz",
+      "integrity": "sha1-Zws6+8VS4LVd9rd4DKdGFfI60cs="
+    },
+    "wide-align": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz",
+      "integrity": "sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==",
+      "requires": {
+        "string-width": "^1.0.2 || 2"
+      }
+    },
     "widest-line": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-2.0.1.tgz",

+ 2 - 0
backend/package.json

@@ -19,6 +19,7 @@
     "@types/jsonwebtoken": "^8.3.8",
     "@types/lodash": "^4.14.149",
     "@types/randombytes": "^2.0.0",
+    "@types/sharp": "^0.24.0",
     "apollo-server-express": "2.11.0",
     "bcryptjs": "^2.4.3",
     "body-parser": "1.19.0",
@@ -34,6 +35,7 @@
     "prisma-binding": "2.3.16",
     "promisify": "0.0.3",
     "randombytes": "^2.1.0",
+    "sharp": "^0.25.2",
     "standard": "14.3.1"
   },
   "devDependencies": {

+ 17 - 0
backend/schema.graphql

@@ -1,6 +1,19 @@
 # import * from './database/generated/prisma.graphql'
 
+scalar Upload
+
+type FsFile {
+  filename: String!
+  path: String!
+  size: Int!
+  ctime: DateTime!
+  mtime: DateTime!
+}
+
 type Query {
+  # File module
+  fsFiles(directory: String!): [FsFile!]!
+  files: [File!]!
   currentUser: User!
   user(where: UserWhereUniqueInput!): User
   users(
@@ -22,6 +35,7 @@ type Query {
     first: Int
     last: Int
   ): [Training!]!
+  publishedTrainings: [Training!]!
   trainingType(where: TrainingTypeWhereUniqueInput!): TrainingType
   trainingTypes(
     where: TrainingTypeWhereInput
@@ -65,6 +79,9 @@ type Query {
 }
 
 type Mutation {
+  # File module
+  uploadFile(file: Upload!, comment: String): File!
+
   createUser(data: UserCreateInput!): User!
   updateUser(email: String!, data: UserUpdateInput!): User
   deleteUser(email: String!): User

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

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

+ 3 - 0
backend/src/file/index.ts

@@ -0,0 +1,3 @@
+import { resolvers } from './resolvers'
+
+export default { resolvers }

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

@@ -0,0 +1,118 @@
+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'
+
+export const resolvers: IResolvers = {
+  Query: {
+    fsFiles: async (parent, { directory }, context, info) => {
+      user.checkPermission(context, 'ADMIN')
+      const data = await fsFiles(directory)
+      return data
+    },
+    files: (parent, args, context, info) => {
+      user.checkPermission(context, 'ADMIN')
+      return context.db.query.files(args, info)
+    }
+  },
+  Mutation: {
+    uploadFile: async (parent, { comment, file }, context, info) => {
+      user.checkPermission(context, 'ADMIN')
+      const fileInfo = await uploadFile(file)
+
+      return context.db.mutation.createFile(
+        {
+          data: {
+            ...fileInfo,
+            comment,
+            user: { connect: { id: context.req.userId } }
+          }
+        },
+        info
+      )
+    }
+  }
+}
+
+async function fsFiles(directory: string) {
+  const fileList = await fs.promises.readdir(directory)
+  return Promise.all(
+    fileList.map(async filename => {
+      const path = `${directory}/${filename}`
+      const { size, ctime, mtime } = await fs.promises.stat(path)
+      return {
+        filename,
+        path,
+        size,
+        ctime,
+        mtime
+      }
+    })
+  )
+}
+
+async function uploadFile(file: any) {
+  const { createReadStream, filename, mimetype, encoding } = await file
+  const stream = createReadStream()
+
+  const fsFilename = randombytes(16).toString('hex')
+  const tmpPath = `${tmpDir}/${fsFilename}`
+  const path = `${uploadDir}/${fsFilename}`
+  const thumbnailPath = `${thumbnails}/${fsFilename}`
+  await new Promise((resolve, reject) => {
+    const file = fs.createWriteStream(tmpPath)
+    file.on('finish', resolve)
+    file.on('error', error => {
+      fs.unlink(tmpPath, () => {
+        reject(error)
+      })
+    })
+    stream.on('error', (error: any) => file.destroy(error))
+    stream.pipe(file)
+  })
+
+  if (mimetype.startsWith('image/')) {
+    try {
+      await processImage(tmpPath, path)
+      await createThumbnail(tmpPath, thumbnailPath)
+      await fs.promises.unlink(tmpPath)
+    } catch (error) {
+      try {
+        await fs.promises.unlink(tmpPath)
+        await fs.promises.unlink(path)
+      } catch (ignore) {}
+      throw error
+    }
+  }
+
+  const { size } = await fs.promises.stat(path)
+
+  return {
+    filename,
+    path,
+    mimetype,
+    encoding,
+    size
+  }
+}
+
+function processImage(tmpFile: string, outputFile: string) {
+  return sharp(tmpFile)
+    .resize(1600, 1600, {
+      fit: sharp.fit.inside,
+      withoutEnlargement: true
+    })
+    .jpeg()
+    .toFile(outputFile)
+}
+
+function createThumbnail(tmpFile: string, thumbnail: string) {
+  return sharp(tmpFile)
+    .resize(200, 200, {
+      fit: sharp.fit.inside
+    })
+    .jpeg()
+    .toFile(thumbnail)
+}

+ 12 - 2
backend/src/training/resolvers.ts

@@ -12,10 +12,16 @@ export const resolvers: IResolvers = {
       return context.db.query.training({ where: args }, info)
     },
     trainings: async (parent, args, context, info) => {
-      checkPermission(context)
-      console.log(info)
+      checkPermission(context, ['INSTRUCTOR', 'ADMIN'])
       return context.db.query.trainings({}, info)
     },
+    publishedTrainings: async (parent, args, context, info) => {
+      checkPermission(context)
+      return context.db.query.trainings(
+        { where: { published: true }, orderBy: 'trainingDate_DESC', first: 20 },
+        info
+      )
+    },
     trainingTypes: async (parent, args, context, info) => {
       checkPermission(context)
       return context.db.query.trainingTypes({}, info)
@@ -27,6 +33,10 @@ export const resolvers: IResolvers = {
     formats: async (parent, args, context, info) => {
       checkPermission(context)
       return context.db.query.formats({}, info)
+    },
+    exercises: async (parent, args, context, info) => {
+      checkPermission(context)
+      return context.db.query.exercises({}, info)
     }
   },
 

+ 3 - 3
frontend/initial-data.ts

@@ -1,6 +1,6 @@
-import { ITraining } from './src/training/types'
+import { TTraining } from './src/training/types'
 
-const data: { trainings: ITraining[]; polls: any } = {
+const data: { trainings: any[]; polls: any } = {
   trainings: [
     {
       id: 'training0',
@@ -20,7 +20,7 @@ const data: { trainings: ITraining[]; polls: any } = {
       blocks: [
         {
           id: 'block0',
-          sequence: 0,
+          order: 0,
           title: 'Drop Sets',
           repetitions: 1,
           rest: 25,

+ 10 - 0
frontend/jest.config.js

@@ -0,0 +1,10 @@
+module.exports = {
+  testEnvironment: 'node',
+  preset: 'ts-jest',
+  setupFilesAfterEnv: ['<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' } }
+}

File diff suppressed because it is too large
+ 309 - 264
frontend/package-lock.json


+ 23 - 20
frontend/package.json

@@ -8,26 +8,29 @@
     "start": "next start",
     "test": "jest",
     "test:watch": "npm run test -- --watchAll",
+    "test:coverage": "npm run test -- --coverage",
     "type-check": "tsc"
   },
   "dependencies": {
-    "@apollo/client": "3.0.0-beta.41",
-    "@apollo/react-ssr": "^3.1.3",
+    "@apollo/client": "3.0.0-beta.43",
+    "@apollo/react-ssr": "^3.1.4",
+    "@fortawesome/fontawesome-svg-core": "^1.2.28",
+    "@fortawesome/free-solid-svg-icons": "^5.13.0",
+    "@fortawesome/react-fontawesome": "^0.1.9",
     "@types/howler": "^2.1.2",
-    "@types/jest": "24.9.1",
     "@types/lodash": "^4.14.149",
     "@types/react-onclickoutside": "^6.7.3",
     "@types/styled-jsx": "^2.2.8",
-    "@types/video.js": "7.3.6",
+    "@types/video.js": "7.3.7",
     "apollo-boost": "0.4.7",
-    "apollo-link": "^1.2.13",
-    "apollo-link-error": "^1.1.12",
+    "apollo-link": "^1.2.14",
+    "apollo-link-error": "^1.1.13",
     "array-move": "^2.2.1",
-    "date-fns": "^2.11.1",
+    "date-fns": "^2.12.0",
     "dotenv": "^8.2.0",
     "formik": "2.1.4",
-    "fuse.js": "3.4.5",
-    "graphql": "14.6.0",
+    "fuse.js": "5.1.0",
+    "graphql": "15.0.0",
     "howler": "^2.1.3",
     "isomorphic-unfetch": "^3.0.0",
     "lodash": "^4.17.15",
@@ -41,26 +44,29 @@
     "react-sortable-hoc": "^1.11.0",
     "standard": "14.3.3",
     "video.js": "^7.7.5",
-    "yup": "^0.27.0"
+    "yup": "^0.28.3"
   },
   "devDependencies": {
-    "@apollo/react-testing": "^3.1.3",
+    "@apollo/react-testing": "^3.1.4",
     "@babel/core": "7.9.0",
-    "@babel/preset-env": "7.9.0",
+    "@babel/preset-env": "7.9.5",
     "@babel/preset-react": "7.9.4",
-    "@testing-library/react": "9.5.0",
+    "@testing-library/react": "10.0.2",
     "@testing-library/react-hooks": "^3.2.1",
     "@types/enzyme": "^3.10.5",
-    "@types/react": "16.9.31",
-    "@types/yup": "0.26.34",
+    "@types/jest": "^25.2.1",
+    "@types/react": "16.9.34",
+    "@types/yup": "0.26.35",
     "@zeit/next-typescript": "^1.1.1",
     "babel-eslint": "10.1.0",
-    "babel-jest": "^24.9.0",
+    "babel-jest": "^25.3.0",
     "enzyme": "^3.11.0",
     "enzyme-adapter-react-16": "^1.15.2",
-    "jest": "^24.9.0",
+    "jest": "^25.3.0",
     "jest-transform-graphql": "^2.1.0",
+    "npm-check-updates": "^4.1.2",
     "react-test-renderer": "16.13.1",
+    "ts-jest": "^25.3.1",
     "typescript": "3.8.3"
   },
   "jest": {
@@ -76,8 +82,5 @@
       ".*": "babel-jest",
       "^.+\\.js?$": "babel-jest"
     }
-  },
-  "standard": {
-    "parser": "babel-eslint"
   }
 }

+ 11 - 0
frontend/pages/admin/index.tsx

@@ -0,0 +1,11 @@
+import Link from 'next/link'
+
+const AdminPage = () => {
+  return (
+    <Link href='training'>
+      <a>Training</a>
+    </Link>
+  )
+}
+
+export default AdminPage

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

@@ -11,7 +11,6 @@ const EditTrainingPage = () => {
 
   if (loading) return <p>Loading data...</p>
   if (error) return <p>Error loading data.</p>
-  console.log(data?.training)
   if (data?.training) return <EditTraining training={data.training} />
   else return <p>Training {id} not found.</p>
 }

+ 10 - 4
frontend/pages/index.tsx

@@ -1,11 +1,11 @@
 import Link from 'next/link'
 
-import initialData from '../initial-data'
-import { useTrainingsQuery } from '../src/gql'
+//import initialData from '../initial-data'
+import { usePublishedTrainingsQuery } from '../src/gql'
 import { Training } from '../src/training'
 
 const Home = () => {
-  //const { data, error, loading } = useTrainingsQuery();
+  const { data, error, loading } = usePublishedTrainingsQuery()
 
   return (
     <>
@@ -25,7 +25,13 @@ const Home = () => {
       </section>
 
       <section id='nextTraining'>
-        <Training training={initialData.trainings[0]} />
+        {loading && <p>Loading trainings...</p>}
+        {error && <p>Error loading trainings: {error.message}</p>}
+        {data?.publishedTrainings && data.publishedTrainings.length > 0 ? (
+          <Training training={data.publishedTrainings[0]} />
+        ) : (
+          <p>No trainings found.</p>
+        )}
       </section>
 
       <style jsx>

+ 0 - 8
frontend/pages/timer.tsx

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

+ 19 - 0
frontend/pages/timer/[id].tsx

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

+ 10 - 2
frontend/pages/training/[id].tsx

@@ -1,10 +1,18 @@
-import EditTraining from '../../src/training/components/EditTraining'
 import { useRouter } from 'next/router'
+import { Training } from '../../src/training'
+import { useTrainingQuery } from '../../src/gql'
 
 const TrainingPage = () => {
   const router = useRouter()
+  const { id } = router.query
+  const { data, error, loading } = useTrainingQuery({
+    variables: { id: typeof id === 'string' ? id : id[0] }
+  })
 
-  return <p>Nothing here yet...</p>
+  if (loading) return <p>Loading data...</p>
+  if (error) return <p>Error loading data.</p>
+  if (data?.training) return <Training training={data.training} />
+  else return <p>Training {id} not found.</p>
 }
 
 export default TrainingPage

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

@@ -6,7 +6,7 @@ const Footer = () => (
     <style jsx>{`
       footer {
         text-align: center;
-        background-color: ${theme.colors.darkgrey};
+        background-color: ${theme.colors.lightgrey};
         color: ${theme.colors.offWhite};
       }
     `}</style>

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

@@ -1,13 +1,36 @@
 import Logo from './Logo'
 import Link from 'next/link'
+import Nav from './Nav'
+import theme from '../../styles/theme'
 
 const Header = () => (
   <header>
     <Logo />
+    <Nav />
 
     <style jsx>{`
       header {
-        padding: 20px;
+        position: relative;
+        text-align: center;
+        background-color: #efefef;
+        width: 100%;
+        z-index: 999;
+        box-shadow: ${theme.bs};
+      }
+
+      @media screen and (min-width: 768px) {
+        header {
+          display: grid;
+          grid-template-columns: 1fr auto minmax(670px, 1fr) 1fr;
+        }
+
+        :global(.logo) {
+          grid-column: 2;
+        }
+
+        :global(nav) {
+          grid-column: 3;
+        }
       }
     `}</style>
   </header>

+ 49 - 30
frontend/src/app/components/Logo.tsx

@@ -1,38 +1,57 @@
 import Link from 'next/link'
 
 const Logo = () => (
-  <Link href='/'>
-    <a>
-      <div id='logo'>
-        <span id='circle'>˙u</span>
-        <span id='text'>fit</span>
-      </div>
-      <style jsx>
-        {`
-          #logo {
-            position: relative;
-            height: 60px;
-            width: 60px;
-            color: white;
-            background-color: red;
-            border-radius: 30px;
-            font-size: 40px;
-            font-weight: 900;
-            padding: 10px 0 0 15px;
-          }
+  <h1 className='logo'>
+    <Link href='/'>
+      <a>
+        <span className='logo-circle'>u</span>
+        <span className='logo-text'>fit</span>
+      </a>
+    </Link>
+    <style jsx>
+      {`
+        .logo {
+          margin: 10px auto;
+        }
+        .logo span {
+          padding-top: 0.3em;
+          font-size: 40px;
+        }
+        .logo-circle {
+          position: relative;
+          display: inline-block;
+          text-align: right;
+          width: 1.55em;
+          height: 1.55em;
+          background-color: red;
+          border-radius: 50%;
+          font-weight: 900;
+          padding-right: 0.2em;
+        }
+        .logo-circle::before {
+          position: absolute;
+          content: '';
+          display: block;
+          width: 0.2em;
+          height: 0.2em;
+          border-radius: 50%;
+          background-color: white;
+          right: 55%;
+          bottom: 55%;
+        }
 
-          a {
-            text-decoration: none;
-          }
+        a {
+          text-decoration: none;
+          color: white;
+        }
 
-          #text {
-            color: black;
-            margin-left: 3px;
-          }
-        `}
-      </style>
-    </a>
-  </Link>
+        .logo .logo-text {
+          color: black;
+          margin-left: 0em;
+        }
+      `}
+    </style>
+  </h1>
 )
 
 export default Logo

+ 171 - 51
frontend/src/app/components/Nav.tsx

@@ -1,68 +1,188 @@
 import Link from 'next/link'
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
+import { faCalendarAlt, faStopwatch20 } from '@fortawesome/free-solid-svg-icons'
 
-import { UserNav } from '../../../src/user/user'
 import theme from '../../styles/theme'
+import UserNav from '../../user/components/UserNav'
 
-const Nav = () => (
-  <nav>
-    <ul>
-      <li>
-        <Link href='/'>
-          <a>Home</a>
-        </Link>
-      </li>
-      <li>
-        <Link href='/training'>
-          <a>Training</a>
-        </Link>
-      </li>
-      <li>
-        <Link href='/login'>
-          <a>Login</a>
-        </Link>
-      </li>
-      <li>
-        <Link href='/signup'>
-          <a>Sign up</a>
-        </Link>
-      </li>
-      <li id='user'>
-        <UserNav />
-      </li>
-    </ul>
-
-    <style jsx>
-      {`
-        ul {
+const Nav = () => {
+  return (
+    <>
+      <input type='checkbox' id='nav-toggle' className='nav-toggle' />
+      <label htmlFor='nav-toggle' className='nav-toggle-label'>
+        <span></span>
+      </label>
+      <nav>
+        <ul>
+          <li>
+            <Link href='/trainings'>
+              <a>
+                <FontAwesomeIcon icon={faCalendarAlt} />
+                Archive
+              </a>
+            </Link>
+          </li>
+          <li>
+            <Link href='/timer'>
+              <a>
+                <FontAwesomeIcon icon={faStopwatch20} />
+                Timer
+              </a>
+            </Link>
+          </li>
+          <UserNav />
+        </ul>
+      </nav>
+
+      <style jsx>{`
+        nav {
+          max-height: 0;
+          overflow: hidden;
+          transition: max-height 400ms ease-in-out;
+          position: absolute;
+          text-align: left;
+          top: 100%;
+          left: 0;
+          width: 100%;
+          background-color: ${theme.colors.nav};
+          align-content: end;
+        }
+
+        .nav-toggle:checked ~ nav {
+          max-height: 100vh;
+        }
+
+        nav ul {
+          margin: 0;
+          padding: 0;
+          list-style: none;
+        }
+
+        :global(nav li) {
+          margin: 0 0.2em;
+          padding: 0.6em 1.5em;
+          border-bottom: 1px solid #696969e7;
+        }
+
+        :global(nav svg) {
+          width: 0.8em;
+          margin-right: 0.7em;
+        }
+
+        :global(nav a) {
+          color: ${theme.colors.offWhite};
+          text-decoration: none;
+          font-size: 1.2rem;
+          text-transform: uppercase;
+        }
+
+        .nav-toggle {
+          display: none;
+        }
+
+        .nav-toggle-label {
+          position: absolute;
+          top: 0;
+          left: 0;
+          margin-left: 1em;
+          height: 100%;
+          display: flex;
+          align-items: center;
+        }
+
+        .nav-toggle-label span {
           display: grid;
+          align-items: center;
+          border: 2px solid #5d6a6bff;
+          transition: all 400ms ease-in-out;
+          height: 4px;
+          width: 2em;
+          border-radius: 2px;
+          margin: 0;
+          padding: 0;
+        }
+        .nav-toggle-label span::before,
+        .nav-toggle-label span::after {
+          content: '';
+          height: 4px;
+          background-color: #5d6a6bff;
+          border-radius: 2px;
+          position: absolute;
+          left: 0.2em;
+          right: 0.2em;
+          transform: rotate(0deg);
+          transition: all 200ms ease-in-out 200ms;
+        }
+        .nav-toggle-label span::before {
+          transform: translate(0, -8px);
+        }
+        .nav-toggle-label span::after {
+          transform: translate(0, 8px);
+        }
+
+        .nav-toggle:checked ~ .nav-toggle-label span {
+          height: 2em;
+          width: 2em;
+          background-color: #5d6a6b00;
+          border: 2px solid #5d6a6b55;
+          border-radius: 0px;
         }
 
-        li {
-          padding: 0 0.5em;
-          border-bottom: 1px solid ${theme.colors.lightgrey};
+        .nav-toggle:checked ~ .nav-toggle-label span::before {
+          transform: rotate(45deg) translate(0);
         }
 
-        @media (min-width: 500px) {
-          ul {
-            grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
-            border-bottom: 1px solid ${theme.colors.lightgrey};
+        .nav-toggle:checked ~ .nav-toggle-label span::after {
+          transform: rotate(-45deg) translate(0);
+        }
+
+        @media screen and (min-width: 768px) {
+          .nav-toggle-label {
+            display: none;
           }
-          li {
-            display: inline;
+          nav {
+            all: unset;
+            display: flex;
+            align-items: center;
+            justify-content: flex-end;
+          }
+
+          :global(nav li) {
+            display: inline-block;
             border-bottom: none;
+            position: relative;
+            text-align: center;
           }
-        }
 
-        #search {
-          grid-column-end: -2;
-        }
+          :global(nav li::after) {
+            content: '';
+            display: block;
+            position: absolute;
+            height: 1px;
+            left: 10%;
+            right: 10%;
+            transform: scale(0, 1);
+            transition: all 300ms ease-in-out;
+            bottom: 0;
+            background-color: ${theme.colors.darkerblue};
+          }
+
+          :global(nav li:hover::after) {
+            background-color: ${theme.colors.blue};
+            transform: scale(1, 1);
+          }
+
+          :global(nav a) {
+            color: ${theme.colors.darkerblue};
+          }
 
-        #user {
-          grid-column-end: -1;
+          :global(nav a:hover) {
+            color: ${theme.colors.blue};
+          }
         }
-      `}
-    </style>
-  </nav>
-)
+      `}</style>
+    </>
+  )
+}
 
 export default Nav

+ 0 - 2
frontend/src/app/components/Page.tsx

@@ -1,7 +1,5 @@
-import Head from 'next/head'
 import Header from './Header'
 import Meta from './Meta'
-import Nav from './Nav'
 import Footer from './Footer'
 import GlobalStyle from '../../styles/global'
 

+ 14 - 5
frontend/src/form/__tests__/useFormHandler.test.tsx

@@ -3,17 +3,26 @@ import { renderHook } from '@testing-library/react-hooks'
 import { useFormHandler } from '../useFormHandler'
 
 describe('form hook return values', () => {
-
   const values = {
     text: 'sample-text',
     number: 42,
     boolean: true,
     textArray: ['element1', 'element2'],
-    objectArray: [{ text: 'sample1', boolean: true }, { text: 'sample2', boolean: false }],
-    object: { text: 'sample', array: [12, 13], nestedObject: { text: 'nested-sample', number: 18 } }
+    objectArray: [
+      { text: 'sample1', boolean: true },
+      { text: 'sample2', boolean: false }
+    ],
+    object: {
+      text: 'sample',
+      array: [12, 13],
+      nestedObject: { text: 'nested-sample', number: 18 }
+    }
   }
 
-  const Component = () => useFormHandler(values, values => { return {} })
+  const Component = () =>
+    useFormHandler(values, values => {
+      return {}
+    })
   const { result } = renderHook(Component)
 
   it('returns correct initial states.', () => {
@@ -39,4 +48,4 @@ describe('form hook return values', () => {
   })
 })
 
-export default true
+export default true

+ 1 - 1
frontend/src/form/components/TextInput.tsx

@@ -22,7 +22,7 @@ const TextInput = ({
       const newValue = {
         target: {
           type: 'custom',
-          value: parseInt(event.target.value),
+          value: parseInt(event.target.value) ?? undefined,
           name: event.target.name
         }
       }

+ 312 - 21
frontend/src/gql/index.tsx

@@ -12,6 +12,7 @@ export type Scalars = {
   Int: number,
   Float: number,
   DateTime: any,
+  Upload: any,
 };
 
 export type Block = Node & {
@@ -1358,6 +1359,20 @@ export type ExerciseWhereUniqueInput = {
   id?: Maybe<Scalars['ID']>,
 };
 
+export type File = Node & {
+  id: Scalars['ID'],
+  createdAt: Scalars['DateTime'],
+  updatedAt: Scalars['DateTime'],
+  path: Scalars['String'],
+  mimetype: Scalars['String'],
+  user: User,
+  thumbnail?: Maybe<Scalars['String']>,
+  filename: Scalars['String'],
+  encoding: Scalars['String'],
+  size: Scalars['Int'],
+  comment?: Maybe<Scalars['String']>,
+};
+
 export type Format = Node & {
   id: Scalars['ID'],
   name: Scalars['String'],
@@ -1495,7 +1510,16 @@ export type FormatWhereUniqueInput = {
   id?: Maybe<Scalars['ID']>,
 };
 
+export type FsFile = {
+  filename: Scalars['String'],
+  path: Scalars['String'],
+  size: Scalars['Int'],
+  ctime: Scalars['DateTime'],
+  mtime: Scalars['DateTime'],
+};
+
 export type Mutation = {
+  uploadFile: File,
   createUser: User,
   updateUser?: Maybe<User>,
   deleteUser?: Maybe<User>,
@@ -1513,6 +1537,12 @@ export type Mutation = {
 };
 
 
+export type MutationUploadFileArgs = {
+  file: Scalars['Upload'],
+  comment?: Maybe<Scalars['String']>
+};
+
+
 export type MutationCreateUserArgs = {
   data: UserCreateInput
 };
@@ -1603,11 +1633,14 @@ export enum Permission {
 }
 
 export type Query = {
+  fsFiles: Array<FsFile>,
+  files: Array<File>,
   currentUser: User,
   user?: Maybe<User>,
   users: Array<User>,
   training?: Maybe<Training>,
   trainings: Array<Training>,
+  publishedTrainings: Array<Training>,
   trainingType?: Maybe<TrainingType>,
   trainingTypes: Array<TrainingType>,
   block?: Maybe<Block>,
@@ -1619,6 +1652,11 @@ export type Query = {
 };
 
 
+export type QueryFsFilesArgs = {
+  directory: Scalars['String']
+};
+
+
 export type QueryUserArgs = {
   where: UserWhereUniqueInput
 };
@@ -2766,6 +2804,7 @@ export type TrainingWhereUniqueInput = {
   id?: Maybe<Scalars['ID']>,
 };
 
+
 export type User = Node & {
   id: Scalars['ID'],
   email: Scalars['String'],
@@ -3314,22 +3353,25 @@ export type UserWhereUniqueInput = {
   email?: Maybe<Scalars['String']>,
 };
 
-export type ExerciseContentFragment = (
-  Pick<ExerciseInstance, 'id' | 'order' | 'repetitions' | 'variation'>
-  & { exercise: Pick<Exercise, 'id' | 'name' | 'description' | 'videos' | 'pictures' | 'targets' | 'baseExercise'> }
-);
+export type ExerciseContentFragment = Pick<Exercise, 'id' | 'name' | 'description' | 'videos' | 'pictures' | 'targets' | 'baseExercise'>;
 
 export type BlockContentFragment = (
   Pick<Block, 'id' | 'title' | 'description' | 'videos' | 'pictures' | 'duration' | 'rest'>
   & { format: Pick<Format, 'id' | 'name' | 'description'>, blocks: Maybe<Array<(
     Pick<BlockInstance, 'id' | 'order' | 'rounds' | 'variation'>
     & { block: BlockHintFragment }
-  )>>, exercises: Maybe<Array<ExerciseContentFragment>> }
+  )>>, exercises: Maybe<Array<(
+    Pick<ExerciseInstance, 'id' | 'order' | 'repetitions' | 'variation'>
+    & { exercise: ExerciseContentFragment }
+  )>> }
 );
 
 export type BlockHintFragment = (
   Pick<Block, 'id' | 'title' | 'description' | 'videos' | 'pictures' | 'duration' | 'rest'>
-  & { format: Pick<Format, 'id' | 'name' | 'description'>, blocks: Maybe<Array<Pick<BlockInstance, 'id'>>>, exercises: Maybe<Array<ExerciseContentFragment>> }
+  & { format: Pick<Format, 'id' | 'name' | 'description'>, blocks: Maybe<Array<Pick<BlockInstance, 'id'>>>, exercises: Maybe<Array<(
+    Pick<ExerciseInstance, 'id' | 'order' | 'repetitions' | 'variation'>
+    & { exercise: ExerciseContentFragment }
+  )>> }
 );
 
 export type SubBlockFragment = (
@@ -3352,6 +3394,51 @@ export type TrainingQuery = { training: Maybe<(
     & { type: Pick<TrainingType, 'id' | 'name' | 'description'>, blocks: Maybe<Array<SubBlockFragment>>, registrations: Maybe<Array<Pick<User, 'id' | 'name'>>> }
   )> };
 
+export type DisplayTrainingFragment = (
+  Pick<Training, 'id' | 'title' | 'trainingDate' | 'location' | 'attendance'>
+  & { type: Pick<TrainingType, 'id' | 'name' | 'description'>, blocks: Maybe<Array<(
+    { block: (
+      { blocks: Maybe<Array<(
+        { block: (
+          { blocks: Maybe<Array<(
+            { block: (
+              { blocks: Maybe<Array<Pick<BlockInstance, 'id'>>> }
+              & DisplayBlockFragment
+            ) }
+            & DisplayBlockInstanceFragment
+          )>> }
+          & DisplayBlockFragment
+        ) }
+        & DisplayBlockInstanceFragment
+      )>> }
+      & DisplayBlockFragment
+    ) }
+    & DisplayBlockInstanceFragment
+  )>>, registrations: Maybe<Array<Pick<User, 'id'>>> }
+);
+
+export type DisplayBlockInstanceFragment = Pick<BlockInstance, 'id' | 'order' | 'rounds' | 'variation'>;
+
+export type DisplayBlockFragment = (
+  Pick<Block, 'id' | 'title' | 'description' | 'videos' | 'pictures' | 'duration' | 'rest'>
+  & { format: Pick<Format, 'name' | 'description'>, exercises: Maybe<Array<(
+    Pick<ExerciseInstance, 'order' | 'repetitions' | 'variation'>
+    & { exercise: DisplayExerciseFragment }
+  )>> }
+);
+
+export type DisplayExerciseInstanceFragment = (
+  Pick<ExerciseInstance, 'id' | 'order' | 'repetitions' | 'variation'>
+  & { exercise: DisplayExerciseFragment }
+);
+
+export type DisplayExerciseFragment = Pick<Exercise, 'id' | 'name' | 'description' | 'videos' | 'pictures' | 'targets' | 'baseExercise'>;
+
+export type PublishedTrainingsQueryVariables = {};
+
+
+export type PublishedTrainingsQuery = { publishedTrainings: Array<DisplayTrainingFragment> };
+
 export type TrainingsQueryVariables = {};
 
 
@@ -3370,6 +3457,16 @@ export type FormatsQueryVariables = {};
 
 export type FormatsQuery = { formats: Array<Pick<Format, 'id' | 'name' | 'description'>> };
 
+export type BlocksQueryVariables = {};
+
+
+export type BlocksQuery = { blocks: Array<BlockContentFragment> };
+
+export type ExercisesQueryVariables = {};
+
+
+export type ExercisesQuery = { exercises: Array<ExerciseContentFragment> };
+
 export type CreateTrainingMutationVariables = {
   title: Scalars['String'],
   type: TrainingTypeCreateOneInput,
@@ -3477,20 +3574,14 @@ export type UserUpdateMutationVariables = {
 export type UserUpdateMutation = { updateUser: Maybe<Pick<User, 'id' | 'name' | 'email' | 'permissions' | 'interests'>> };
 
 export const ExerciseContentFragmentDoc = gql`
-    fragment exerciseContent on ExerciseInstance {
+    fragment exerciseContent on Exercise {
   id
-  exercise {
-    id
-    name
-    description
-    videos
-    pictures
-    targets
-    baseExercise
-  }
-  order
-  repetitions
-  variation
+  name
+  description
+  videos
+  pictures
+  targets
+  baseExercise
 }
     `;
 export const BlockHintFragmentDoc = gql`
@@ -3511,7 +3602,13 @@ export const BlockHintFragmentDoc = gql`
     id
   }
   exercises {
-    ...exerciseContent
+    id
+    exercise {
+      ...exerciseContent
+    }
+    order
+    repetitions
+    variation
   }
 }
     ${ExerciseContentFragmentDoc}`;
@@ -3539,7 +3636,13 @@ export const BlockContentFragmentDoc = gql`
     variation
   }
   exercises {
-    ...exerciseContent
+    id
+    exercise {
+      ...exerciseContent
+    }
+    order
+    repetitions
+    variation
   }
 }
     ${BlockHintFragmentDoc}
@@ -3566,6 +3669,98 @@ export const SubBlockHintFragmentDoc = gql`
   variation
 }
     ${BlockHintFragmentDoc}`;
+export const DisplayBlockInstanceFragmentDoc = gql`
+    fragment displayBlockInstance on BlockInstance {
+  id
+  order
+  rounds
+  variation
+}
+    `;
+export const DisplayExerciseFragmentDoc = gql`
+    fragment displayExercise on Exercise {
+  id
+  name
+  description
+  videos
+  pictures
+  targets
+  baseExercise
+}
+    `;
+export const DisplayBlockFragmentDoc = gql`
+    fragment displayBlock on Block {
+  id
+  title
+  description
+  videos
+  pictures
+  duration
+  format {
+    name
+    description
+  }
+  rest
+  exercises {
+    order
+    repetitions
+    variation
+    exercise {
+      ...displayExercise
+    }
+  }
+}
+    ${DisplayExerciseFragmentDoc}`;
+export const DisplayTrainingFragmentDoc = gql`
+    fragment displayTraining on Training {
+  id
+  title
+  type {
+    id
+    name
+    description
+  }
+  trainingDate
+  location
+  attendance
+  blocks {
+    ...displayBlockInstance
+    block {
+      ...displayBlock
+      blocks {
+        ...displayBlockInstance
+        block {
+          ...displayBlock
+          blocks {
+            ...displayBlockInstance
+            block {
+              ...displayBlock
+              blocks {
+                id
+              }
+            }
+          }
+        }
+      }
+    }
+  }
+  registrations {
+    id
+  }
+}
+    ${DisplayBlockInstanceFragmentDoc}
+${DisplayBlockFragmentDoc}`;
+export const DisplayExerciseInstanceFragmentDoc = gql`
+    fragment displayExerciseInstance on ExerciseInstance {
+  id
+  order
+  repetitions
+  variation
+  exercise {
+    ...displayExercise
+  }
+}
+    ${DisplayExerciseFragmentDoc}`;
 export const TrainingDocument = gql`
     query training($id: ID!) {
   training(id: $id) {
@@ -3617,6 +3812,38 @@ export function useTrainingLazyQuery(baseOptions?: ApolloReactHooks.LazyQueryHoo
 export type TrainingQueryHookResult = ReturnType<typeof useTrainingQuery>;
 export type TrainingLazyQueryHookResult = ReturnType<typeof useTrainingLazyQuery>;
 export type TrainingQueryResult = ApolloReactCommon.QueryResult<TrainingQuery, TrainingQueryVariables>;
+export const PublishedTrainingsDocument = gql`
+    query publishedTrainings {
+  publishedTrainings {
+    ...displayTraining
+  }
+}
+    ${DisplayTrainingFragmentDoc}`;
+
+/**
+ * __usePublishedTrainingsQuery__
+ *
+ * To run a query within a React component, call `usePublishedTrainingsQuery` and pass it any options that fit your needs.
+ * When your component renders, `usePublishedTrainingsQuery` returns an object from Apollo Client that contains loading, error, and data properties 
+ * you can use to render your UI.
+ *
+ * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
+ *
+ * @example
+ * const { data, loading, error } = usePublishedTrainingsQuery({
+ *   variables: {
+ *   },
+ * });
+ */
+export function usePublishedTrainingsQuery(baseOptions?: ApolloReactHooks.QueryHookOptions<PublishedTrainingsQuery, PublishedTrainingsQueryVariables>) {
+        return ApolloReactHooks.useQuery<PublishedTrainingsQuery, PublishedTrainingsQueryVariables>(PublishedTrainingsDocument, baseOptions);
+      }
+export function usePublishedTrainingsLazyQuery(baseOptions?: ApolloReactHooks.LazyQueryHookOptions<PublishedTrainingsQuery, PublishedTrainingsQueryVariables>) {
+          return ApolloReactHooks.useLazyQuery<PublishedTrainingsQuery, PublishedTrainingsQueryVariables>(PublishedTrainingsDocument, baseOptions);
+        }
+export type PublishedTrainingsQueryHookResult = ReturnType<typeof usePublishedTrainingsQuery>;
+export type PublishedTrainingsLazyQueryHookResult = ReturnType<typeof usePublishedTrainingsLazyQuery>;
+export type PublishedTrainingsQueryResult = ApolloReactCommon.QueryResult<PublishedTrainingsQuery, PublishedTrainingsQueryVariables>;
 export const TrainingsDocument = gql`
     query trainings {
   trainings {
@@ -3734,6 +3961,70 @@ export function useFormatsLazyQuery(baseOptions?: ApolloReactHooks.LazyQueryHook
 export type FormatsQueryHookResult = ReturnType<typeof useFormatsQuery>;
 export type FormatsLazyQueryHookResult = ReturnType<typeof useFormatsLazyQuery>;
 export type FormatsQueryResult = ApolloReactCommon.QueryResult<FormatsQuery, FormatsQueryVariables>;
+export const BlocksDocument = gql`
+    query blocks {
+  blocks {
+    ...blockContent
+  }
+}
+    ${BlockContentFragmentDoc}`;
+
+/**
+ * __useBlocksQuery__
+ *
+ * To run a query within a React component, call `useBlocksQuery` and pass it any options that fit your needs.
+ * When your component renders, `useBlocksQuery` returns an object from Apollo Client that contains loading, error, and data properties 
+ * you can use to render your UI.
+ *
+ * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
+ *
+ * @example
+ * const { data, loading, error } = useBlocksQuery({
+ *   variables: {
+ *   },
+ * });
+ */
+export function useBlocksQuery(baseOptions?: ApolloReactHooks.QueryHookOptions<BlocksQuery, BlocksQueryVariables>) {
+        return ApolloReactHooks.useQuery<BlocksQuery, BlocksQueryVariables>(BlocksDocument, baseOptions);
+      }
+export function useBlocksLazyQuery(baseOptions?: ApolloReactHooks.LazyQueryHookOptions<BlocksQuery, BlocksQueryVariables>) {
+          return ApolloReactHooks.useLazyQuery<BlocksQuery, BlocksQueryVariables>(BlocksDocument, baseOptions);
+        }
+export type BlocksQueryHookResult = ReturnType<typeof useBlocksQuery>;
+export type BlocksLazyQueryHookResult = ReturnType<typeof useBlocksLazyQuery>;
+export type BlocksQueryResult = ApolloReactCommon.QueryResult<BlocksQuery, BlocksQueryVariables>;
+export const ExercisesDocument = gql`
+    query exercises {
+  exercises {
+    ...exerciseContent
+  }
+}
+    ${ExerciseContentFragmentDoc}`;
+
+/**
+ * __useExercisesQuery__
+ *
+ * To run a query within a React component, call `useExercisesQuery` and pass it any options that fit your needs.
+ * When your component renders, `useExercisesQuery` returns an object from Apollo Client that contains loading, error, and data properties 
+ * you can use to render your UI.
+ *
+ * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
+ *
+ * @example
+ * const { data, loading, error } = useExercisesQuery({
+ *   variables: {
+ *   },
+ * });
+ */
+export function useExercisesQuery(baseOptions?: ApolloReactHooks.QueryHookOptions<ExercisesQuery, ExercisesQueryVariables>) {
+        return ApolloReactHooks.useQuery<ExercisesQuery, ExercisesQueryVariables>(ExercisesDocument, baseOptions);
+      }
+export function useExercisesLazyQuery(baseOptions?: ApolloReactHooks.LazyQueryHookOptions<ExercisesQuery, ExercisesQueryVariables>) {
+          return ApolloReactHooks.useLazyQuery<ExercisesQuery, ExercisesQueryVariables>(ExercisesDocument, baseOptions);
+        }
+export type ExercisesQueryHookResult = ReturnType<typeof useExercisesQuery>;
+export type ExercisesLazyQueryHookResult = ReturnType<typeof useExercisesLazyQuery>;
+export type ExercisesQueryResult = ApolloReactCommon.QueryResult<ExercisesQuery, ExercisesQueryVariables>;
 export const CreateTrainingDocument = gql`
     mutation createTraining($title: String!, $type: TrainingTypeCreateOneInput!, $trainingDate: DateTime!, $location: String!, $attendance: Int!, $published: Boolean!, $blocks: BlockInstanceCreateManyWithoutParentTrainingInput) {
   createTraining(title: $title, type: $type, trainingDate: $trainingDate, location: $location, attendance: $attendance, published: $published, blocks: $blocks) {

+ 2 - 26
frontend/src/modal/components/Modal.tsx

@@ -1,5 +1,6 @@
 import React, { FunctionComponent, useEffect, useRef } from 'react'
 import { createPortal } from 'react-dom'
+import { modal } from '../styles'
 
 interface IModal {
   state: [boolean, Function]
@@ -22,32 +23,7 @@ const Modal: FunctionComponent<IModal> = ({ state, children }) => {
         </button>
         {children}
       </div>
-      <style jsx>{`
-        .modal {
-          position: absolute;
-          top: 0;
-          left: 0;
-          height: 100vh;
-          width: 100vw;
-          background-color: #000000bb;
-        }
-        .modal > .container {
-          margin: 2em auto;
-          padding: 2em;
-          background-color: #e0e0e0;
-          max-width: 768px;
-          width: 90vw;
-          border-radius: 5px;
-          box-shadow: 0px 0px 5px 5px #77777777;
-        }
-        .modal > .container > button.modal-close {
-          float: right;
-          padding: 0.3em 0.8em;
-          border: none;
-          color: white;
-          background-color: black;
-        }
-      `}</style>
+      <style jsx>{modal}</style>
     </div>
   ) : null
 

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

@@ -0,0 +1,28 @@
+import css from 'styled-jsx/css'
+
+export const modal = css`
+  .modal {
+    position: absolute;
+    top: 0;
+    left: 0;
+    height: 100vh;
+    width: 100vw;
+    background-color: #000000bb;
+  }
+  .modal > .container {
+    margin: 2em auto;
+    padding: 2em;
+    background-color: #e0e0e0;
+    max-width: 768px;
+    width: 90vw;
+    border-radius: 5px;
+    box-shadow: 0px 0px 5px 5px #77777777;
+  }
+  .modal > .container > button.modal-close {
+    float: right;
+    padding: 0.3em 0.8em;
+    border: none;
+    color: white;
+    background-color: black;
+  }
+`

+ 4 - 71
frontend/src/sortable/components/SortableList.tsx

@@ -1,48 +1,13 @@
-import React, { useState } from 'react'
 import {
   SortableContainer,
   SortableElement,
   SortableHandle
 } from 'react-sortable-hoc'
-import arrayMove from 'array-move'
+import { dragHandle, sortableItem, sortableList } from '../styles'
 
 const DragHandle = SortableHandle(() => (
   <div className='listitem-drag-handle'>
-    <style jsx>{`
-      div {
-        width: 20px;
-        height: 15px;
-        user-select: none;
-        cursor: row-resize;
-        align-items: center;
-        background: linear-gradient(
-          top,
-          #0006,
-          #0006 20%,
-          #fff0 0,
-          #fff0 40%,
-          #0006 0,
-          #0006 60%,
-          #fff0 0,
-          #fff0 80%,
-          #0006 0,
-          #0006
-        );
-        background: -webkit-linear-gradient(
-          top,
-          #0006,
-          #0006 20%,
-          #fff0 0,
-          #fff0 40%,
-          #0006 0,
-          #0006 60%,
-          #fff0 0,
-          #fff0 80%,
-          #0006 0,
-          #0006
-        );
-      }
-    `}</style>
+    <style jsx>{dragHandle}</style>
   </div>
 ))
 
@@ -51,12 +16,7 @@ const SortableItem = SortableElement(({ item }: any) => (
     <DragHandle />
     <div className='listitem-content'>{item}</div>
 
-    <style jsx>{`
-      li {
-        display: grid;
-        grid-template-columns: 30px 1fr;
-      }
-    `}</style>
+    <style jsx>{sortableItem}</style>
   </li>
 ))
 
@@ -66,36 +26,9 @@ const SortableList = SortableContainer(({ items }: { items: any[] }) => {
       {items.map((item: any, index: number) => {
         return <SortableItem key={item.key} index={index} item={item} />
       })}
-      <style jsx>{`
-        ul {
-          padding: 0;
-        }
-      `}</style>
+      <style jsx>{sortableList}</style>
     </ul>
   )
 })
 
-const SortableComponent = ({ items }: { items: any[] }) => {
-  const [state, setState] = useState(items)
-
-  function onSortEnd({
-    oldIndex,
-    newIndex
-  }: {
-    oldIndex: number
-    newIndex: number
-  }) {
-    const a = arrayMove(state, oldIndex, newIndex)
-    setState(a)
-  }
-  return (
-    <SortableList
-      onSortEnd={onSortEnd}
-      items={state}
-      lockAxis='y'
-      useDragHandle={true}
-    />
-  )
-}
-
 export default SortableList

+ 50 - 0
frontend/src/sortable/styles/index.ts

@@ -0,0 +1,50 @@
+import css from 'styled-jsx/css'
+
+export const dragHandle = css`
+  div {
+    width: 20px;
+    height: 15px;
+    user-select: none;
+    cursor: row-resize;
+    align-items: center;
+    background: linear-gradient(
+      top,
+      #0006,
+      #0006 20%,
+      #fff0 0,
+      #fff0 40%,
+      #0006 0,
+      #0006 60%,
+      #fff0 0,
+      #fff0 80%,
+      #0006 0,
+      #0006
+    );
+    background: -webkit-linear-gradient(
+      top,
+      #0006,
+      #0006 20%,
+      #fff0 0,
+      #fff0 40%,
+      #0006 0,
+      #0006 60%,
+      #fff0 0,
+      #fff0 80%,
+      #0006 0,
+      #0006
+    );
+  }
+`
+
+export const sortableItem = css`
+  li {
+    display: grid;
+    grid-template-columns: 30px 1fr;
+  }
+`
+
+export const sortableList = css`
+  ul {
+    padding: 0;
+  }
+`

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

@@ -27,14 +27,11 @@ const GlobalStyle = css.global`
     display: grid;
     grid-template-areas:
       'header'
-      'nav'
       'main'
       'footer';
-    grid-template-rows: auto auto 1fr minmax(180px, auto);
+    grid-template-rows: auto auto 1fr minmax(140px, auto);
 
-    max-width: ${theme.maxWidth};
     min-height: 100vh;
-    margin: 0 auto;
 
     background: ${theme.colors.offWhite};
     color: ${theme.colors.black};
@@ -44,7 +41,7 @@ const GlobalStyle = css.global`
   @media (min-width: 500px) {
     body #__next {
       grid-template-areas:
-        'header nav'
+        'header header'
         'main main'
         'footer footer';
       grid-template-columns: auto 1fr;
@@ -55,11 +52,10 @@ const GlobalStyle = css.global`
   header {
     grid-area: header;
   }
-  nav {
-    grid-area: nav;
-  }
   main {
     grid-area: main;
+    max-width: ${theme.maxWidth};
+    margin: 0 auto;
   }
   footer {
     grid-area: footer;
@@ -73,6 +69,7 @@ const GlobalStyle = css.global`
   h5,
   h6 {
     font-weight: 900;
+    color: ${theme.colors.darkerblue};
   }
 
   /* Use monospace font for pre */

+ 2 - 1
frontend/src/styles/theme.ts

@@ -11,7 +11,8 @@ const theme = {
     blue: '#4482c3',
     darkblue: '#285680',
     darkerblue: '#204567',
-    offWhite: '#EDEDED'
+    offWhite: '#EDEDED',
+    nav: '#393939e7'
   },
   maxWidth: '1000px',
   bs: '0 12px 24px 0 rgba(0,0,0,0.09)',

+ 2 - 2
frontend/src/timer/components/Timer.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect, useRef } from 'react'
 
-import { ITraining } from '../../training/types'
+import { TTraining } from '../../training/types'
 import { getExerciseList, getTrainingTime, getPosition } from '../utils'
 
 import Countdown from './Countdown'
@@ -11,7 +11,7 @@ import { useVoice } from '../hooks/useVoice'
 import { useVideo } from '../hooks/useVideo'
 import theme from '../../styles/theme'
 
-const Timer = ({ training }: { training: ITraining }) => {
+const Timer = ({ training }: { training: TTraining }) => {
   const [time, timer] = useTimer({ tickPeriod: 100 })
   const voice = useVoice('rosie')
 

+ 17 - 13
frontend/src/timer/utils.ts

@@ -1,6 +1,6 @@
-import { IBlock } from '../training/types'
 import { calculateDuration } from '../training/utils'
 import { IExerciseItem } from './types'
+import { TBlock, TBlockInstance } from '../training/types'
 
 /**
  * Find the right exercise given a certain time.
@@ -36,16 +36,18 @@ export function getPosition(exerciseList: IExerciseItem[], time: number) {
  * @param initialOffset - used for recursive application
  */
 export function getExerciseList(
-  blocks: IBlock[],
+  blockInstances?: TBlockInstance[],
   initialOffset = 0,
   toplevelBlock: undefined | string = undefined
 ): IExerciseItem[] {
+  if (!blockInstances) return []
   let offset = initialOffset
-  return blocks
-    .map(block => {
+  return blockInstances
+    .map(blockInstance => {
+      const { block, rounds = 1 } = blockInstance
       if (block.blocks) {
         const blockArray = []
-        for (let i = 0; i < (block.repetitions || 1); i++) {
+        for (let i = 0; i < rounds; i++) {
           const subBlocks = getExerciseList(
             block.blocks,
             offset,
@@ -74,19 +76,21 @@ export function getExerciseList(
         const blockArray: IExerciseItem[] = []
         const newItem = {
           exercise: block.exercises
-            .map(exercise =>
-              exercise.repetitions > 1
-                ? `${exercise.repetitions}x ${exercise.name}`
-                : exercise.name
-            )
+            .map(exerciseInstance => {
+              const {
+                exercise: { name },
+                repetitions = 1
+              } = exerciseInstance
+              return repetitions > 1 ? `${repetitions}x ${name}` : name
+            })
             .join(' - '),
-          duration: calculateDuration(block),
-          video: block.video,
+          duration: calculateDuration(blockInstance),
+          videos: block.videos,
           description: block.description,
           toplevelBlock: toplevelBlock || block.title,
           offset
         }
-        for (let i = 0; i < (block.repetitions || 1); i++) {
+        for (let i = 0; i < rounds; i++) {
           blockArray.push({ ...newItem, offset })
           offset += newItem.duration
         }

+ 63 - 0
frontend/src/training/__tests__/utils.test.ts

@@ -0,0 +1,63 @@
+import { diffDB } from '../utils'
+const testDate1 = new Date(2020, 1, 1)
+const testDate2 = new Date(2020, 2, 2)
+console.log(typeof testDate2)
+const typesFalsy = {
+  number: 0,
+  boolean: false,
+  string: '',
+  //date: testDate1,
+  array: [],
+  object: {},
+  undefined: undefined,
+  null: null
+}
+const expectedFalsy = {
+  number: 0,
+  boolean: false,
+  string: ''
+  //date: testDate1
+}
+const typesTruthy = {
+  number: 12,
+  boolean: true,
+  string: 'hello!',
+  //date: testDate2,
+  array: [1, 2, 3],
+  object: { a: 1, b: 2 },
+  undefined: 'not undefined',
+  null: 45
+}
+const dbId = 'dbid'
+const createId = '++createId'
+const updateId = '@@updateId'
+const deleteId = '--deleteId'
+
+const dbItem = {
+  id: dbId,
+  ...typesTruthy,
+  child: {
+    id: dbId,
+    ...typesTruthy
+  }
+}
+
+const updateItem = {
+  id: dbId,
+  ...typesTruthy
+}
+
+describe('diffDB: Find differences of current state to database', () => {
+  it('returns undefined if there are no differences', () => {
+    const truthyNoDiffs = diffDB(typesTruthy, typesTruthy)
+    expect(truthyNoDiffs).toBe(undefined)
+    const falsyNoDiffs = diffDB(typesFalsy, typesFalsy)
+    expect(falsyNoDiffs).toBe(undefined)
+  })
+  it('diffs types correctly', () => {
+    const truthyDiff = diffDB(typesTruthy, typesFalsy)
+    expect(truthyDiff).toEqual(typesTruthy)
+    const falsyDiff = diffDB(typesFalsy, typesTruthy)
+    expect(falsyDiff).toEqual(expectedFalsy)
+  })
+})

+ 23 - 6
frontend/src/training/components/BlockInputs.tsx

@@ -1,9 +1,15 @@
 import FormatSelector from './FormatSelector'
 import { TextInput } from '../../form'
 import BlockInstanceInputs from './BlockInstanceInputs'
-import { emptyBlockInstance } from '../utils'
+import {
+  emptyBlockInstance,
+  emptyExercise,
+  emptyExerciseInstance
+} from '../utils'
 import ExerciseInstanceInputs from './ExerciseInstanceInputs'
 import { TBlock } from '../types'
+import { useState, ChangeEvent } from 'react'
+import BlockSelector from './BlockSelector'
 
 interface IBlockInputs {
   onChange: GenericEventHandler
@@ -14,6 +20,15 @@ interface IBlockInputs {
 const BlockInputs = ({ onChange, value, name }: IBlockInputs) => {
   return (
     <>
+      <p>
+        {value.id} {value.title}
+      </p>
+      <BlockSelector
+        name={name}
+        value={value}
+        label='Existing block'
+        onChange={onChange}
+      />
       <TextInput
         name={`${name}.title`}
         label='Title'
@@ -74,7 +89,7 @@ const BlockInputs = ({ onChange, value, name }: IBlockInputs) => {
       <label>Exercises</label>
       {value.exercises && value.exercises.length > 0 && (
         <ExerciseInstanceInputs
-          name={`${name}.blocks`}
+          name={`${name}.exercises`}
           value={value.exercises}
           onChange={onChange}
         />
@@ -82,14 +97,16 @@ const BlockInputs = ({ onChange, value, name }: IBlockInputs) => {
       <button
         onClick={event => {
           event.preventDefault()
-          const newExercise = empty({
-            order: value.blocks ? value.blocks.length : 0
+          const newExercise = emptyExerciseInstance({
+            order: value.exercises ? value.exercises.length : 0
           })
           onChange({
             target: {
               type: 'custom',
-              name: `${name}.blocks`,
-              value: value.blocks ? [...value.blocks, newBlock] : [newBlock]
+              name: `${name}.exercises`,
+              value: value.exercises
+                ? [...value.exercises, newExercise]
+                : [newExercise]
             }
           })
         }}

+ 64 - 0
frontend/src/training/components/BlockSelector.tsx

@@ -0,0 +1,64 @@
+import { useBlocksQuery } from '../../gql'
+import { useState, useEffect } from 'react'
+import { TBlock } from '../types'
+
+interface IBlockSelector {
+  value?: TBlock
+  onChange: GenericEventHandler
+  name?: string
+  label?: string
+}
+
+const BlockSelector = ({
+  value,
+  onChange,
+  name = 'block',
+  label = 'Block'
+}: IBlockSelector) => {
+  const [state, setState] = useState(value?.id ?? '')
+  const blocks = useBlocksQuery()
+
+  useEffect(() => {
+    setState(value?.id || '')
+  }, [value])
+
+  return (
+    <>
+      <label>{label}</label>
+      <select
+        id={name}
+        name={name}
+        value={state}
+        onChange={ev => setState(ev.target.value)}
+      >
+        {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>
+          ))}
+      </select>
+      <button
+        type='button'
+        onClick={event => {
+          const changeEvent: CustomChangeEvent = {
+            target: {
+              type: 'custom',
+              value: { id: state },
+              name
+            }
+          }
+          onChange(changeEvent)
+        }}
+      >
+        Use
+      </button>
+    </>
+  )
+}
+
+export default BlockSelector

+ 7 - 3
frontend/src/training/components/ExerciseComposition.tsx

@@ -1,8 +1,8 @@
 import { formatTime, printExercises } from '../utils'
-import { IExercise } from '../types'
+import { TExerciseInstance } from '../types'
 
 export interface IExerciseComposition {
-  exercises: IExercise[]
+  exercises: TExerciseInstance[]
   duration: number
 }
 
@@ -11,7 +11,7 @@ const ExerciseComposition = ({ exercises, duration }: IExerciseComposition) => {
 
   return (
     <div className='exercise-composition'>
-      <span>{exerciseString}</span>
+      <span className='exercise-name'>{exerciseString}</span>
       <span className='exercise-time'>{formatTime(duration)}</span>
 
       <style jsx>
@@ -23,6 +23,10 @@ const ExerciseComposition = ({ exercises, duration }: IExerciseComposition) => {
           .exercise-composition .exercise-time {
             text-align: right;
           }
+          .exercise-time {
+            color: gray;
+            font-size: 80%;
+          }
         `}
       </style>
     </div>

+ 8 - 0
frontend/src/training/components/ExerciseInputs.tsx

@@ -1,5 +1,6 @@
 import { TextInput } from '../../form'
 import { TExercise } from '../types'
+import ExerciseSelector from './ExerciseSelector'
 
 interface IExerciseInputs {
   onChange: GenericEventHandler
@@ -10,6 +11,13 @@ interface IExerciseInputs {
 const ExerciseInputs = ({ onChange, value, name }: IExerciseInputs) => {
   return (
     <>
+      <p>ex: {value.id}</p>
+      <ExerciseSelector
+        name={name}
+        value={value}
+        label='Existing exercise'
+        onChange={onChange}
+      />
       <TextInput
         name={`${name}.name`}
         label='Name'

+ 2 - 2
frontend/src/training/components/ExerciseInstanceInputs.tsx

@@ -80,7 +80,7 @@ const ExerciseInstanceInputs = ({
           />
           {item.exercise && (
             <ExerciseInputs
-              name={`${name}.${itemIndex}.block`}
+              name={`${name}.${itemIndex}.exercise`}
               value={item.exercise}
               onChange={onChange}
             />
@@ -100,7 +100,7 @@ const ExerciseInstanceInputs = ({
               updateOrderProperty(newValues, newOrder)
             }}
           >
-            Delete block
+            Delete Exercise
           </button>
         </div>
       )

+ 66 - 0
frontend/src/training/components/ExerciseSelector.tsx

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

+ 2 - 2
frontend/src/training/components/FormatSelector.tsx

@@ -43,7 +43,7 @@ const FormatSelector = ({
           const changeEvent: CustomChangeEvent = {
             target: {
               type: 'custom',
-              value: { connect: { id: event.target.value } },
+              value: { id: event.target.value },
               name
             }
           }
@@ -74,7 +74,7 @@ const FormatSelector = ({
               const changeEvent: CustomChangeEvent = {
                 target: {
                   type: 'custom',
-                  value: { connect: { id: result.data.createFormat.id } },
+                  value: { id: result.data.createFormat.id },
                   name
                 }
               }

+ 11 - 9
frontend/src/training/components/Training.tsx

@@ -2,12 +2,10 @@ import theme from '../../styles/theme'
 
 import TrainingBlock from './TrainingBlock'
 import Link from 'next/link'
-import { ITraining } from '../types'
+import { TTraining } from '../types'
 import TrainingMeta from './TrainingMeta'
-import { useRouter } from 'next/router'
-import { useTrainingLazyQuery } from '../../gql'
 
-const Training = ({ training }: { training: ITraining }) => {
+const Training = ({ training }: { training: TTraining }) => {
   return (
     <article>
       <h2>{training.title}</h2>
@@ -16,13 +14,16 @@ const Training = ({ training }: { training: ITraining }) => {
 
       <section>
         <h2>Program</h2>
-        <Link href='/timer'>
-          <button>Start Timer</button>
+        <Link href='/timer/[id]' as={`/timer/${training.id}`}>
+          <button type='button'>Start Timer</button>
         </Link>
         {training.blocks &&
           training.blocks
-            .sort(block => block.sequence || 0)
-            .map(block => <TrainingBlock key={block.id} block={block} />)}
+            .slice()
+            .sort((a, b) => a.order - b.order)
+            .map(block => (
+              <TrainingBlock key={block.id} blockInstance={block} />
+            ))}
       </section>
 
       <style jsx>
@@ -34,9 +35,10 @@ const Training = ({ training }: { training: ITraining }) => {
               'information placeholder'
               'content content';
             grid-template-columns: 1fr 2fr;
-            background-image: url('media/man_working_out.jpg');
+            background-image: url('/media/man_working_out.jpg');
             background-size: auto 400px;
             background-repeat: no-repeat;
+            background-position: center 0;
             margin: 2em 0;
           }
 

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

@@ -1,20 +1,23 @@
-import ExerciseComposition from "./ExerciseComposition";
-import { calculateDuration, formatTime } from "../utils";
-import { IBlock } from "../types";
+import ExerciseComposition from './ExerciseComposition'
+import { calculateDuration, formatTime } from '../utils'
+import { TBlockInstance } from '../types'
 
-const TrainingBlock = ({ block }: { block: IBlock }) => {
-  const duration = calculateDuration(block);
+const TrainingBlock = ({
+  blockInstance
+}: {
+  blockInstance: TBlockInstance
+}) => {
+  const duration = calculateDuration(blockInstance)
+  const { title, blocks, exercises } = blockInstance.block
   return (
     <div>
-      {block.title && (
-        <h3>
-          {block.title} ({formatTime(duration)})
-        </h3>
-      )}
-      {block.blocks &&
-        block.blocks.map(block => <TrainingBlock block={block} />)}
-      {block.exercises && (
-        <ExerciseComposition exercises={block.exercises} duration={duration} />
+      <h3>
+        <span className='block-title'>{title}</span>{' '}
+        <span className='block-time'>{formatTime(duration)}</span>
+      </h3>
+      {blocks && blocks.map(block => <TrainingBlock blockInstance={block} />)}
+      {exercises && (
+        <ExerciseComposition exercises={exercises} duration={duration} />
       )}
 
       <style jsx>
@@ -22,9 +25,19 @@ const TrainingBlock = ({ block }: { block: IBlock }) => {
           section {
             display: grid;
           }
+          .block-time {
+            color: gray;
+            font-size: 90%;
+          }
+          .block-time::before {
+            content: '(';
+          }
+          .block-time::after {
+            content: ')';
+          }
         `}
       </style>
     </div>
-  );
-};
-export default TrainingBlock;
+  )
+}
+export default TrainingBlock

+ 38 - 20
frontend/src/training/components/TrainingMeta.tsx

@@ -1,28 +1,46 @@
-import { ITraining } from "../types";
-import { calculateRating } from "../utils";
+import { TTraining } from '../types'
+import { calculateRating } from '../utils'
+import { useContext } from 'react'
+import { UserContext } from '../../user/hooks'
+import { useRegisterMutation } from '../../gql'
+
+const TrainingMeta = ({ training }: { training: TTraining }) => {
+  const { user } = useContext(UserContext)
+  const [register, registerData] = useRegisterMutation({
+    variables: { training: training.id }
+  })
 
-const TrainingMeta = ({ training }: { training: ITraining }) => {
   return (
     <aside>
-      <div className="info">
-        <span className="caption">Type: </span>
-        <span className="data">{training.type.name}</span>
+      <div className='info'>
+        <span className='caption'>Type: </span>
+        <span className='data'>{training.type.name}</span>
       </div>
-      <div className="info">
-        <span className="caption">Date: </span>
-        <span className="data">
+      <div className='info'>
+        <span className='caption'>Date: </span>
+        <span className='data'>
           {new Date(training.trainingDate).toLocaleString()}
         </span>
       </div>
-      <div className="info">
-        <span className="caption">Location: </span>
-        <span className="data">{training.location}</span>
+      <div className='info'>
+        <span className='caption'>Location: </span>
+        <span className='data'>{training.location}</span>
       </div>
-      {/*<div className="info">
-        <span className="caption">Registrations: </span>
-        <span className="data">{training.registrations.length} </span>
+      <div className='info'>
+        <span className='caption'>Registrations: </span>
+        <span className='data'>{training.registrations?.length ?? 0}</span>
+        {training.registrations &&
+        training.registrations.find(
+          registeredUser =>
+            user?.data?.currentUser &&
+            registeredUser.id === user.data.currentUser.id
+        ) ? (
+          <button onClick={() => register()}>Deregister</button>
+        ) : (
+          <button onClick={() => register()}>Register now!</button>
+        )}
       </div>
-      <div className="info">
+      {/*<div className="info">
         <span className="caption">Attendance: </span>
         <span className="data">{training.attendance}</span>
       </div>
@@ -38,7 +56,7 @@ const TrainingMeta = ({ training }: { training: ITraining }) => {
           <a href="">*</a>
         </span>
       </div>
-          <button>Register now!</button>*/}
+          */}
 
       <style jsx>{`
         aside {
@@ -55,7 +73,7 @@ const TrainingMeta = ({ training }: { training: ITraining }) => {
         }
       `}</style>
     </aside>
-  );
-};
+  )
+}
 
-export default TrainingMeta;
+export default TrainingMeta

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

@@ -1,7 +1,7 @@
 import { useTrainingTypesQuery, TrainingType } from '../../gql'
-import { Modal } from '../../modal'
 import AddTrainingType from './AddTrainingType'
 import { useState, useEffect } from 'react'
+import { Modal } from '../../modal'
 
 interface ITrainingTypeSelector {
   value?: TrainingType

+ 125 - 15
frontend/src/training/training.graphql

@@ -1,19 +1,13 @@
 # import * from '../../../backend/database/generated/prisma.graphql'
 
-fragment exerciseContent on ExerciseInstance {
+fragment exerciseContent on Exercise {
   id
-  exercise {
-    id
-    name
-    description
-    videos
-    pictures
-    targets
-    baseExercise
-  }
-  order
-  repetitions
-  variation
+  name
+  description
+  videos
+  pictures
+  targets
+  baseExercise
 }
 
 fragment blockContent on Block {
@@ -39,7 +33,13 @@ fragment blockContent on Block {
     variation
   }
   exercises {
-    ...exerciseContent
+    id
+    exercise {
+      ...exerciseContent
+    }
+    order
+    repetitions
+    variation
   }
 }
 
@@ -60,7 +60,13 @@ fragment blockHint on Block {
     id
   }
   exercises {
-    ...exerciseContent
+    id
+    exercise {
+      ...exerciseContent
+    }
+    order
+    repetitions
+    variation
   }
 }
 
@@ -108,6 +114,98 @@ query training($id: ID!) {
   }
 }
 
+fragment displayTraining on Training {
+  id
+  title
+  type {
+    id
+    name
+    description
+  }
+  trainingDate
+  location
+  attendance
+  blocks {
+    ...displayBlockInstance
+    block {
+      ...displayBlock
+      blocks {
+        ...displayBlockInstance
+        block {
+          ...displayBlock
+          blocks {
+            ...displayBlockInstance
+            block {
+              ...displayBlock
+              blocks {
+                id
+              }
+            }
+          }
+        }
+      }
+    }
+  }
+  registrations {
+    id
+  }
+}
+
+fragment displayBlockInstance on BlockInstance {
+  id
+  order
+  rounds
+  variation
+}
+
+fragment displayBlock on Block {
+  id
+  title
+  description
+  videos
+  pictures
+  duration
+  format {
+    name
+    description
+  }
+  rest
+  exercises {
+    order
+    repetitions
+    variation
+    exercise {
+      ...displayExercise
+    }
+  }
+}
+
+fragment displayExerciseInstance on ExerciseInstance {
+  id
+  order
+  repetitions
+  variation
+  exercise {
+    ...displayExercise
+  }
+}
+
+fragment displayExercise on Exercise {
+  id
+  name
+  description
+  videos
+  pictures
+  targets
+  baseExercise
+}
+
+query publishedTrainings {
+  publishedTrainings {
+    ...displayTraining
+  }
+}
+
 query trainings {
   trainings {
     id
@@ -147,6 +245,18 @@ query formats {
   }
 }
 
+query blocks {
+  blocks {
+    ...blockContent
+  }
+}
+
+query exercises {
+  exercises {
+    ...exerciseContent
+  }
+}
+
 mutation createTraining(
   $title: String!
   $type: TrainingTypeCreateOneInput!

+ 41 - 63
frontend/src/training/types.ts

@@ -6,66 +6,44 @@ import {
   Exercise
 } from '../gql'
 
-export interface ITraining {
-  id: string
-  title: string
-  type: {
-    id: string
-    name: string
-    description: string
-  }
-  createdAt: string
-  trainingDate: string
-  location: string
-  registrations: string[]
-  attendance: number
-  ratings: IRating[]
-  published: boolean
-  blocks: IBlock[]
-}
-
-export type TTraining = Pick<Training, 'id'> & Partial<Omit<Training, 'id'>>
-export type TBlockInstance = Pick<BlockInstance, 'id'> &
-  Partial<Omit<BlockInstance, 'id'>>
-export type TBlock = Pick<Block, 'id'> & Partial<Omit<Block, 'id'>>
-export type TExerciseInstance = Pick<ExerciseInstance, 'id'> &
-  Partial<Omit<ExerciseInstance, 'id'>>
-export type TExercise = Pick<Exercise, 'id'> & Partial<Omit<Exercise, 'id'>>
-
-export interface IBlock {
-  id: string
-  sequence?: number
-  title?: string
-  description?: string
-  video?: string
-  duration?: number
-  repetitions?: number
-  rest?: number
-  format?: IFormat
-  blocks?: IBlock[]
-  exercises?: IExercise[]
-}
-
-export interface IFormat {
-  id: string
-  name: string
-  description: string
-}
-
-export interface IExercise {
-  id: string
-  name: string
-  description: string
-  repetitions: number
-  videos: string[]
-  pictures: string[]
-  targets: string[]
-  baseExercise: {
-    id: string
-    name: string
-  }
-}
-
-export interface IRating {
-  value: number
-}
+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 TBlock = Pick<
+  Block,
+  | 'id'
+  | 'title'
+  | 'exercises'
+  | 'videos'
+  | 'blocks'
+  | 'tracks'
+  | 'pictures'
+  | 'format'
+> &
+  Partial<
+    Omit<
+      Block,
+      | 'id'
+      | 'title'
+      | 'exercises'
+      | 'videos'
+      | 'blocks'
+      | 'tracks'
+      | 'pictures'
+      | 'format'
+    >
+  >
+export type TExerciseInstance = Pick<ExerciseInstance, 'id' | 'exercise'> &
+  Partial<Omit<ExerciseInstance, 'id' | 'exercise'>>
+export type TExercise = Pick<
+  Exercise,
+  'id' | 'name' | 'pictures' | 'videos' | 'targets' | 'baseExercise'
+> &
+  Partial<
+    Omit<
+      Exercise,
+      'id' | 'name' | 'pictures' | 'videos' | 'targets' | 'baseExercise'
+    >
+  >
+export type TRating = { value: number }

+ 64 - 53
frontend/src/training/utils.ts

@@ -1,20 +1,19 @@
 import { parse } from 'date-fns'
 import {
-  IBlock,
-  IExercise,
-  IRating,
   TTraining,
   TExerciseInstance,
   TBlockInstance,
   TBlock,
-  TExercise
+  TExercise,
+  TRating
 } from './types'
 import {
   TrainingQuery,
   SubBlockFragment,
-  BlockContentFragment,
-  Training,
-  ExerciseContentFragment
+  Exercise,
+  Block,
+  ExerciseInstance,
+  BlockInstance
 } from '../gql'
 import { isArray, transform, isEqual, isObject } from 'lodash'
 
@@ -22,20 +21,21 @@ import { isArray, transform, isEqual, isObject } from 'lodash'
  * Takes a block of exercises and calculates the duration in seconds.
  * @param block
  */
-export function calculateDuration(block: IBlock): number {
-  if (block.duration) return block.duration
-  const repetitions = block.repetitions || 1
-  const rest = block.rest || 0
-  if (block.blocks) {
-    const subblockDuration = block.blocks.reduce(
-      (accumulator, block) =>
-        accumulator + (block.duration || calculateDuration(block)),
+export function calculateDuration(
+  blocks?: TBlockInstance | TBlockInstance[]
+): number {
+  if (!blocks) return 0
+  const blocksArray = isArray(blocks) ? blocks : [blocks]
+  const duration = blocksArray.map(blockInstance => {
+    const blockRounds = blockInstance.rounds ?? 1
+    const blockDuration =
+      blockInstance.block.duration ??
+      calculateDuration(blockInstance.block.blocks) ??
       0
-    )
-    return repetitions * (subblockDuration + rest)
-  } else {
-    return 0
-  }
+    const blockRest = blockInstance.block.rest ?? 0
+    return blockRounds * (blockDuration + blockRest)
+  })
+  return duration.reduce((a, b) => a + b, 0)
 }
 
 /**
@@ -53,12 +53,12 @@ export function formatTime(seconds: number) {
  * 4x Exercise 1 - Exercise 2 - 2x Exercise 3
  * @param exercises
  */
-export function printExercises(exercises: IExercise[]) {
+export function printExercises(exercises: TExerciseInstance[]) {
   return exercises
-    .map(exercise =>
-      exercise.repetitions > 1
-        ? `${exercise.repetitions}x ${exercise.name}`
-        : exercise.name
+    .map(exerciseInstance =>
+      exerciseInstance.repetitions && exerciseInstance.repetitions > 1
+        ? `${exerciseInstance.repetitions}x ${exerciseInstance.exercise.name}`
+        : exerciseInstance.exercise.name
     )
     .join(' - ')
 }
@@ -67,7 +67,7 @@ export function printExercises(exercises: IExercise[]) {
  * Takes an array of rating and calculates the average rating
  * @param ratings
  */
-export function calculateRating(ratings: IRating[]) {
+export function calculateRating(ratings: TRating[]) {
   const numberOfRatings = ratings.length
   const sumOfRatings = ratings.reduce(
     (accumulator, rating) => accumulator + rating.value,
@@ -83,8 +83,6 @@ export function trainingDBToArray(DBTraining: TrainingQuery['training']) {
   return { ...data, blocks: blockDBToArray(blocks) }
 }
 
-export function trainingArrayToDB() {}
-
 export function blockDBToArray(DBSubBlock?: SubBlockFragment[]) {
   console.log({ DBSubBlock })
   if (!DBSubBlock) return undefined
@@ -123,8 +121,8 @@ function randomID() {
     .substr(0, 10)}`
 }
 
-export function emptyExercise(input?: TExercise) {
-  const emptyExercise = {
+export function emptyExercise(input?: Partial<Exercise>) {
+  const emptyExercise: TExercise = {
     id: randomID(),
     name: '',
     description: '',
@@ -136,8 +134,8 @@ export function emptyExercise(input?: TExercise) {
   return { ...emptyExercise, ...input }
 }
 
-export function emptyExerciseInstance(input?: TExerciseInstance) {
-  const emptyExerciseInstance = {
+export function emptyExerciseInstance(input?: Partial<ExerciseInstance>) {
+  const emptyExerciseInstance: TExerciseInstance = {
     id: randomID(),
     order: 0,
     exercise: emptyExercise()
@@ -145,8 +143,8 @@ export function emptyExerciseInstance(input?: TExerciseInstance) {
   return { ...emptyExerciseInstance, ...input }
 }
 
-export function emptyBlock(input?: TBlock) {
-  const emptyBlock = {
+export function emptyBlock(input?: Partial<Block>) {
+  const emptyBlock: TBlock = {
     id: randomID(),
     title: '',
     format: { id: '', name: '', description: '' },
@@ -158,8 +156,8 @@ export function emptyBlock(input?: TBlock) {
   return { ...emptyBlock, ...input }
 }
 
-export function emptyBlockInstance(input?: TBlockInstance) {
-  const emptyBlockInstance = {
+export function emptyBlockInstance(input?: Partial<BlockInstance>) {
+  const emptyBlockInstance: TBlockInstance = {
     id: randomID(),
     block: emptyBlock(),
     order: 0
@@ -168,7 +166,7 @@ export function emptyBlockInstance(input?: TBlockInstance) {
 }
 
 export function emptyTraining(input?: TTraining) {
-  const emptyTraining = {
+  const emptyTraining: TTraining = {
     id: randomID(),
     title: '',
     type: { id: '', name: '', description: '' },
@@ -180,11 +178,12 @@ export function emptyTraining(input?: TTraining) {
   return { ...emptyTraining, ...input }
 }
 
-export function collectMutationCreateConnect(arr: any[]) {
+export function collectMutations(arr: any[]) {
   const create: any[] = []
   const connect: any[] = []
   const del: any[] = []
   const update: any[] = []
+  console.log('collect', arr)
   arr.forEach(val => {
     if (typeof val === 'object' && val['connect']) connect.push(val['connect'])
     if (typeof val === 'object' && val['create']) create.push(val['create'])
@@ -204,17 +203,19 @@ export function collectMutationCreateConnect(arr: any[]) {
   return returnObject
 }
 
+function isNotInDB(key: any, val: any) {
+  return (
+    key === '__typename' ||
+    (key === 'id' && typeof val === 'string' && val.startsWith('++'))
+  )
+}
+
 export function transformArrayToDB(acc: any, val: any, key: any, object: any) {
-  if (key === '__typename') {
-    // remove the typename from the database
-    return
-  } else if (key === 'id' && typeof val === 'string' && val.startsWith('++')) {
-    // remove placeholder IDs
-    return
-  } else if (isArray(val)) {
-    // collect 'create' and 'connect' statements
-    acc[key] = collectMutationCreateConnect(transform(val, transformArrayToDB))
+  if (isNotInDB(key, val)) return
+  if (isArray(val)) {
+    acc[key] = collectMutations(transform(val, transformArrayToDB))
   } else if (typeof val === 'object' && !!val) {
+    console.log('object', key, val)
     // we found an object!
     if (!!val['id'] && val['id'].startsWith('++')) {
       // values with placeholder IDs are preserved
@@ -223,7 +224,6 @@ export function transformArrayToDB(acc: any, val: any, key: any, object: any) {
       // IDs starting with -- are for deletion
       acc[key] = { delete: { id: val['id'].substr(2) } }
     } else {
-      // values with real IDs are just connected
       const { id, ...data } = val
       if (id?.startsWith('@@')) {
         if (typeof key === 'string') {
@@ -236,7 +236,6 @@ export function transformArrayToDB(acc: any, val: any, key: any, object: any) {
             }
           }
         }
-        console.log('update candidate', key, id, data, acc[key].update.data)
       } else {
         acc[key] = { connect: { id } }
       }
@@ -251,15 +250,27 @@ export function diffDB(newObject: any = {}, oldObject: any = {}) {
   const transformResult = transform(
     newObject,
     (result: any, value: any, key: string) => {
-      if (key === 'id') {
-        if (isEqual(value, oldObject[key])) result[key] = `@@${value}`
-      } else if (!isEqual(value, oldObject[key])) {
+      /*if (key === 'id') {
+        result[key] = value
+      } else*/ if (
+        !isEqual(value, oldObject[key])
+      ) {
         const newValue =
           isObject(value) && isObject(oldObject[key])
             ? diffDB(value, oldObject[key])
             : value
         if (newValue !== undefined && newValue !== null) {
-          result[key] = newValue
+          if (isEqual(key, 'id')) {
+            if (result[key] !== undefined) return
+          } else {
+            result[key] = newValue
+            if (
+              oldObject['id'] !== undefined &&
+              oldObject['id'] === newObject['id']
+            ) {
+              result['id'] = `@@${oldObject['id']}`
+            }
+          }
         }
       }
     }

+ 33 - 17
frontend/src/user/components/LoginForm.tsx

@@ -1,25 +1,41 @@
-import { useUserLoginMutation, CurrentUserDocument } from "../../gql";
-import { TextInput } from "../../form";
-
-const initialValues = {
-  email: "tomislav.cvetic@u-blox.com",
-  password: "1234"
-};
+import { TextInput, useForm } from '../../form'
+import { useContext } from 'react'
+import { UserContext } from '../hooks'
 
 const LoginForm = () => {
-  const [login, { loading, error, data }] = useUserLoginMutation();
-  console.log("LoginForm", loading, error, data);
+  const { values, onChange } = useForm({ email: '', password: '' })
+  const { login } = useContext(UserContext)
+
+  if (!login) return <p>Loading context.</p>
+  const [userLogin, { error, loading }] = login
 
   return (
-    <form>
-      <TextInput label="Email" name="email" type="text" placeholder="Email" />
-      <TextInput label="Password" name="password" type="password" />
-      <button type="submit" disabled={loading}>
+    <form
+      onSubmit={async ev => {
+        ev.preventDefault()
+        const result = await userLogin({ variables: values })
+        console.log('login', result)
+      }}
+    >
+      <TextInput
+        label='Email'
+        name='email'
+        value={values.email}
+        onChange={onChange}
+      />
+      <TextInput
+        label='Password'
+        name='password'
+        type='password'
+        value={values.password}
+        onChange={onChange}
+      />
+      <button type='submit' disabled={loading}>
         Login!
       </button>
-      {error && <div className="error">{error.message}</div>}
+      {error && <div className='error'>{error.message}</div>}
     </form>
-  );
-};
+  )
+}
 
-export default LoginForm;
+export default LoginForm

+ 9 - 13
frontend/src/user/components/LogoutButton.tsx

@@ -1,24 +1,20 @@
-import { useUserLogoutMutation, CurrentUserDocument } from '../../gql'
+import { useContext } from 'react'
+import { UserContext } from '../hooks'
 
 interface LogoutButtonProps {
   title?: string
 }
 
-const LogoutButton = ({ title }: LogoutButtonProps) => {
+const LogoutButton = ({ title = 'Logout' }: LogoutButtonProps) => {
+  const { logout } = useContext(UserContext)
 
-  const [logout, { loading, error }] = useUserLogoutMutation(
-    { refetchQueries: [{ query: CurrentUserDocument }] }
-  )
+  if (!logout) return <p>Loading context.</p>
+  const [userLogout, { error, loading }] = logout
 
   return (
-    <button disabled={loading} onClick={async (event: React.SyntheticEvent) => {
-      try {
-        const data = await logout()
-        console.log('LogoutButton', data)
-      } catch (error) {
-        console.log('LogoutButton', error)
-      }
-    }}>{title || 'Log out'}</button>
+    <button disabled={loading} onClick={event => userLogout()}>
+      {title}
+    </button>
   )
 }
 

+ 45 - 0
frontend/src/user/components/UserNav.tsx

@@ -0,0 +1,45 @@
+import { useState, useContext } from 'react'
+import Link from 'next/link'
+import LogoutButton from './LogoutButton'
+import LoginForm from './LoginForm'
+import { UserContext } from '../hooks'
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
+import { faUser } from '@fortawesome/free-solid-svg-icons'
+
+const UserNav = () => {
+  const [menu, setMenu] = useState(false)
+  const { user } = useContext(UserContext)
+
+  return (
+    <li>
+      <a
+        href=''
+        onClick={ev => {
+          ev.preventDefault()
+          setMenu(!menu)
+        }}
+      >
+        <FontAwesomeIcon icon={faUser} />
+        {user?.data ? user.data.currentUser.name : 'Login'}
+      </a>
+
+      {menu ? (
+        <section className='usermenu'>
+          {user?.data ? (
+            <>
+              <h2>Welcome, {user.data.currentUser.name}</h2>
+              <Link href={{ pathname: 'user' }}>
+                <a>Edit user data</a>
+              </Link>
+              <LogoutButton />
+            </>
+          ) : (
+            <LoginForm />
+          )}
+        </section>
+      ) : null}
+    </li>
+  )
+}
+
+export default UserNav

+ 2 - 1
frontend/src/user/components/__tests__/DeleteUserButton.test.tsx

@@ -1,3 +1,4 @@
+import React from 'react'
 import { shallow } from 'enzyme'
 import { MockedProvider } from '@apollo/client/testing'
 
@@ -28,4 +29,4 @@ describe('testing delete user button', () => {
       </MockedProvider>
     )
   })
-})
+})

+ 23 - 6
frontend/src/user/hooks.tsx

@@ -1,14 +1,31 @@
 import { createContext, FunctionComponent } from 'react'
-import { CurrentUserQuery, useCurrentUserQuery } from '../gql'
+import {
+  CurrentUserQuery,
+  useCurrentUserQuery,
+  useUserLogoutMutation,
+  useUserLoginMutation,
+  CurrentUserDocument
+} from '../gql'
 
-export const UserContext = createContext<
-  CurrentUserQuery['currentUser'] | undefined
->(undefined)
+interface IUserContext {
+  user?: ReturnType<typeof useCurrentUserQuery>
+  login?: ReturnType<typeof useUserLoginMutation>
+  logout?: ReturnType<typeof useUserLogoutMutation>
+}
+
+export const UserContext = createContext<IUserContext>({})
 
 export const UserProvider: FunctionComponent = ({ children }) => {
-  const user = useCurrentUserQuery()
+  const user = useCurrentUserQuery({ fetchPolicy: 'network-only' })
+  const logout = useUserLogoutMutation({
+    refetchQueries: [{ query: CurrentUserDocument }]
+  })
+  const login = useUserLoginMutation({
+    refetchQueries: [{ query: CurrentUserDocument }]
+  })
+  console.log('current user', user.data)
   return (
-    <UserContext.Provider value={user.data?.currentUser}>
+    <UserContext.Provider value={{ user, login, logout }}>
       {children}
     </UserContext.Provider>
   )

+ 0 - 62
frontend/src/user/user.js

@@ -1,62 +0,0 @@
-import { useState, useEffect } from 'react'
-import Link from 'next/link'
-import { useQuery } from '@apollo/client'
-import { CURRENT_USER } from './graphql'
-import LogoutButton from './components/LogoutButton'
-import LoginForm from './components/LoginForm'
-
-const UserNav = props => {
-  const [menu, setMenu] = useState(false)
-
-  return (
-    <>
-      <a
-        href='' onClick={ev => {
-          ev.preventDefault()
-          setMenu(!menu)
-        }}
-      >sali
-      </a>
-      {menu ? (
-        <UserNavMenu />
-      ) : null}
-    </>
-  )
-}
-
-const UserNavMenu = props => {
-  const { data, loading, error } = useQuery(CURRENT_USER, { fetchPolicy: 'cache-and-network' })
-  console.log('UserNav', data, loading, error && error.message)
-  const user = data && data.me
-
-  if (loading) return <p>Loading user data...</p>
-  if (error) return <p>Error loading user data.</p>
-
-  return (
-    <section className='usermenu'>
-      {user ? (
-        <>
-          <h2>Welcome, {user.name}</h2>
-          <Link href={{ pathname: 'user' }}><a>Edit user data</a></Link>
-          <LogoutButton />
-        </>
-      ) : (
-          <LoginForm />
-        )}
-
-      <style jsx>
-        {`
-          section.usermenu {
-          position: absolute;
-          background: rgba(127,0,0,0.5);
-          }
-        `}
-      </style>
-    </section>
-  )
-}
-
-const User = props => <a />
-
-export { UserNav }
-export default User

+ 6 - 0
frontend/tsconfig.jest.json

@@ -0,0 +1,6 @@
+{
+  "extends": "./tsconfig.json",
+  "compilerOptions": {
+    "jsx": "react"
+  }
+}

+ 3 - 2
frontend/tsconfig.json

@@ -1,6 +1,6 @@
 {
   "compilerOptions": {
-    "target": "ESNext",
+    "target": "es5",
     "module": "ESNext",
     "jsx": "preserve",
     "moduleResolution": "Node",
@@ -10,7 +10,8 @@
     "allowJs": true,
     "skipLibCheck": true,
     "forceConsistentCasingInFileNames": true,
-    "noEmit": true,
+    "noEmit": false,
+    "outDir": "./tsout",
     "esModuleInterop": true,
     "resolveJsonModule": true,
     "isolatedModules": true

Some files were not shown because too many files changed in this diff