Parcourir la source

improved file data structure. removed video type.

Tomi Cvetic il y a 4 ans
Parent
commit
e3a4d099d9
40 fichiers modifiés avec 3510 ajouts et 1713 suppressions
  1. 435 302
      backend/database/generated/prisma-client/index.ts
  2. 552 279
      backend/database/generated/prisma-client/prisma-schema.ts
  3. 731 105
      backend/database/generated/prisma.graphql
  4. 46 29
      backend/datamodel.prisma
  5. 2 2
      backend/package.json
  6. 12 7
      backend/schema.graphql
  7. 24 0
      backend/src/file/constants.ts
  8. 30 102
      backend/src/file/resolvers.ts
  9. 119 0
      backend/src/file/utils.ts
  10. 579 282
      backend/src/gql/resolvers.ts
  11. 0 1
      backend/src/user/resolvers.ts
  12. 6 6
      frontend/Dockerfile
  13. 5 0
      frontend/package-lock.json
  14. 1 0
      frontend/package.json
  15. 3 3
      frontend/pages/admin/block/index.tsx
  16. 3 3
      frontend/pages/admin/exercise/index.tsx
  17. 3 0
      frontend/pages/admin/file/[id].tsx
  18. 20 0
      frontend/pages/admin/file/create.tsx
  19. 65 7
      frontend/pages/admin/file/index.tsx
  20. 4 6
      frontend/pages/admin/training/index.tsx
  21. 0 3
      frontend/pages/admin/video/[id].tsx
  22. 0 23
      frontend/pages/admin/video/create.tsx
  23. 0 55
      frontend/pages/admin/video/index.tsx
  24. 40 17
      frontend/src/app/components/AdminList.tsx
  25. 0 5
      frontend/src/app/components/AdminSideBar.tsx
  26. 1 1
      frontend/src/audioplayer/components/AudioPlayer.tsx
  27. 31 0
      frontend/src/audioplayer/components/PlayButton.tsx
  28. 4 0
      frontend/src/audioplayer/index.ts
  29. 59 0
      frontend/src/file/api.tsx
  30. 68 0
      frontend/src/file/components/EditFile.tsx
  31. 0 61
      frontend/src/file/components/EditVideo.tsx
  32. 14 7
      frontend/src/file/components/VideoSelector.tsx
  33. 45 81
      frontend/src/file/file.graphql
  34. 579 283
      frontend/src/gql/index.tsx
  35. 1 1
      frontend/src/training/components/BlockInstanceInputs.tsx
  36. 2 7
      frontend/src/training/components/BlockList.tsx
  37. 16 19
      frontend/src/training/components/BlockSelector.tsx
  38. 5 6
      frontend/src/training/components/EditTraining.tsx
  39. 3 9
      frontend/src/training/training.graphql
  40. 2 1
      frontend/src/training/utils.ts

Fichier diff supprimé car celui-ci est trop grand
+ 435 - 302
backend/database/generated/prisma-client/index.ts


Fichier diff supprimé car celui-ci est trop grand
+ 552 - 279
backend/database/generated/prisma-client/prisma-schema.ts


Fichier diff supprimé car celui-ci est trop grand
+ 731 - 105
backend/database/generated/prisma.graphql


+ 46 - 29
backend/datamodel.prisma

@@ -10,7 +10,6 @@ type User {
     ratings: [Rating!]!
     permissions: [Permission!]!  @scalarList(strategy: RELATION)
     interests: [String!]!  @scalarList(strategy: RELATION)
-    avatar: Picture
 }
 
 enum Permission {
@@ -46,8 +45,8 @@ type Block {
     format: Format!
     rest: Int
     tracks: [Track!]!
-    videos: [String!]! @scalarList(strategy: RELATION)
-    pictures: [String!]! @scalarList(strategy: RELATION)
+    videos: [Video!]! 
+    pictures: [Picture!]!
     blocks: [BlockInstance!] @relation(name: "Instances", onDelete: CASCADE)
     parentBlockInstances: [BlockInstance!]! @relation(name: "ParentChild", onDelete: CASCADE)
     exercises: [ExerciseInstance!]! @relation(name: "BlockExercises", onDelete: CASCADE)
@@ -69,52 +68,70 @@ type Format {
     description: String!
 }
 
-type File {
+type Picture {
     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
+    order: Int!
+    file: File
+    link: Link
 }
 
-type Track {
+type Video {
     id: ID! @id
-    title: String!
-    artist: String!
-    duration: Int!
+    createdAt: DateTime! @createdAt
+    updatedAt: DateTime! @updatedAt
+    order: Int!
     file: File
-    link: String
+    link: Link
 }
 
-type Video {
+type Track {
     id: ID! @id
-    title: String!
-    description: String!
-    duration: Int!
+    createdAt: DateTime! @createdAt
+    updatedAt: DateTime! @updatedAt
+    order: Int!
     file: File
-    link: String
+    link: Link
 }
 
-type Picture {
+type File {
     id: ID! @id
-    title: String!
-    description: String!
-    file: File
-    link: String
+    createdAt: DateTime! @createdAt
+    updatedAt: DateTime! @updatedAt
+    user: User!
+    path: String!
+    comment: String
+    mimetype: String
+    thumbnail: String
+    filename: String
+    encoding: String
+    size: Int
+    width: Int
+    height: Int
+    duration: Float
+    title: String
+    artist: String
+}
+
+type Link {
+    id: ID! @id
+    createdAt: DateTime! @createdAt
+    updatedAt: DateTime! @updatedAt
+    url: String!
+    user: User!
+    comment: String
+    duration: Int
+    title: String
+    artist: String
 }
 
 type Exercise {
     id: ID! @id
     name: String!
     description: String
-    videos: [String!]! @scalarList(strategy: RELATION)
-    pictures: [String!]! @scalarList(strategy: RELATION)
+    videos: [Video!]! 
+    pictures: [Picture!]! 
     targets: [String!]! @scalarList(strategy: RELATION)
     baseExercise: [String!]! @scalarList(strategy: RELATION)
     parentExerciseInstances: [ExerciseInstance!]! 

+ 2 - 2
backend/package.json

@@ -17,7 +17,7 @@
   "dependencies": {
     "@types/bcryptjs": "^2.4.2",
     "@types/cookie-parser": "^1.4.2",
-    "@types/fluent-ffmpeg": "^2.1.14",
+    "@types/fluent-ffmpeg": "2.1.14",
     "@types/jsonwebtoken": "^8.3.9",
     "@types/lodash": "^4.14.150",
     "@types/randombytes": "^2.0.0",
@@ -29,7 +29,7 @@
     "cors": "2.8.5",
     "date-fns": "2.11.1",
     "dotenv": "8.2.0",
-    "fluent-ffmpeg": "^2.1.2",
+    "fluent-ffmpeg": "2.1.2",
     "graphql": "^14.6.0",
     "jsonwebtoken": "^8.5.1",
     "lodash": "^4.17.15",

+ 12 - 7
backend/schema.graphql

@@ -59,8 +59,7 @@ type Query {
     first: Int
     last: Int
   ): [File!]!
-  videos: [Video!]!
-  video(where: VideoWhereUniqueInput): Video!
+  file(where: FileWhereUniqueInput!): File!
 
   # User module
   currentUser: User!
@@ -118,11 +117,17 @@ type Query {
 
 type Mutation {
   # File module
-  uploadFile(file: Upload!, comment: String): File!
-  createVideo(data: VideoCreateInput): Video!
-  updateVideo(data: VideoUpdateInput!, where: VideoWhereUniqueInput!): Video!
-  createVideo(data: VideoCreateInput): Video!
-  updateVideo(data: VideoUpdateInput!, where: VideoWhereUniqueInput!): Video!
+  uploadFile(
+    file: Upload!
+    comment: String
+    width: Int
+    height: Int
+    duration: Float
+    title: String
+    artist: String
+  ): File!
+  updateFile(where: FileWhereUniqueInput!, data: FileUpdateInput!): File!
+  deleteFile(id: ID!): File!
 
   # User module
   createUser(data: UserCreateInput!): User!

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

@@ -1,2 +1,26 @@
+import sharp from 'sharp'
+
 export const tmpDir = 'upload_files/tmp'
 export const uploadDir = 'upload_files'
+
+export const resizeOptions = {
+  maxWidth: 1600,
+  maxHeight: 1600,
+  options: {
+    fit: sharp.fit.inside,
+    withoutEnlargement: true,
+  },
+}
+
+export const thumbnailOptions = {
+  maxWidth: 200,
+  maxHeight: 200,
+  options: {
+    fit: sharp.fit.inside,
+  },
+}
+
+export const screenshotOptions = {
+  timestamps: ['25%'],
+  size: '200x?',
+}

+ 30 - 102
backend/src/file/resolvers.ts

@@ -1,10 +1,9 @@
 import { IResolvers } from 'apollo-server-express'
 import fs from 'fs'
 import randombytes from 'randombytes'
-import sharp from 'sharp'
-import ffmpeg from 'fluent-ffmpeg'
-import { tmpDir, uploadDir } from './constants'
+import { uploadDir } from './constants'
 import { checkPermission } from '../user/resolvers'
+import { saveStreamToFile, processImage, processVideo, processAudio } from './utils'
 
 export const resolvers: IResolvers = {
   Query: {
@@ -17,38 +16,40 @@ export const resolvers: IResolvers = {
       checkPermission(context, 'ADMIN')
       return context.db.query.files(args, info)
     },
-    videos: (parent, args, context, info) => {
-      checkPermission(context)
-      return context.db.query.videos(args, info)
-    },
-    video: (parent, args, context, info) => {
+    file: (parent, args, context, info) => {
       checkPermission(context, 'ADMIN')
-      return context.db.query.video(args, info)
+      return context.db.query.file(args, info)
     },
   },
   Mutation: {
-    uploadFile: async (parent, { comment, file }, context, info) => {
+    uploadFile: async (parent, { file, ...args }, context, info) => {
       checkPermission(context, 'ADMIN')
       const fileInfo = await uploadFile(file)
 
       return context.db.mutation.createFile(
         {
           data: {
+            ...args,
             ...fileInfo,
-            comment,
             user: { connect: { id: context.req.userId } },
           },
         },
         info
       )
     },
-    createVideo: (parent, args, context, info) => {
+    updateFile: (parent, args, context, info) => {
       checkPermission(context, 'ADMIN')
-      return context.db.mutation.createVideo(args, info)
+      return context.db.mutation.updateFile(args, info)
     },
-    updateVideo: (parent, args, context, info) => {
+    deleteFile: async (parent, { id }, context, info) => {
       checkPermission(context, 'ADMIN')
-      return context.db.mutation.updateVideo(args, info)
+      const file = await context.db.query.file({ where: { id } })
+      if (!file) throw Error(`File '${id}' not found.`)
+      console.log(file)
+      try {
+        await fs.promises.unlink(file.path)
+      } catch (error) {}
+      return context.db.mutation.deleteFile({ where: { id } })
     },
   },
 }
@@ -73,99 +74,26 @@ async function fsFiles(directory: string) {
 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}`
-  let thumbnail = null
-  const thumbnailFile = `thmb${fsFilename}`
-  const thumbnailPath = `${uploadDir}/${thumbnailFile}`
-  await new Promise((resolve, reject) => {
-    const file = fs.createWriteStream(tmpPath)
-    file.on('finish', resolve)
-    file.on('error', (error) => {
-      fs.unlink(tmpPath, () => {
-        reject(error)
-      })
-    })
-    stream.on('error', (error: any) => file.destroy(error))
-    stream.pipe(file)
-  })
+  const path: [string, string] = [uploadDir, fsFilename]
+  const filePath = path.join('/')
+
+  let fileDetails = { filename, mimetype, encoding, path: filePath }
 
   if (mimetype.startsWith('image/')) {
-    console.log('image')
-    try {
-      await processImage(tmpPath, path)
-      await createThumbnail(tmpPath, thumbnailPath)
-      await fs.promises.unlink(tmpPath)
-      thumbnail = thumbnailPath
-    } catch (error) {
-      try {
-        await fs.promises.unlink(tmpPath)
-        await fs.promises.unlink(path)
-      } catch (ignore) {}
-      throw error
-    }
+    const imageDetails = await processImage(stream, path)
+    fileDetails = { ...fileDetails, ...imageDetails }
   } else if (mimetype.startsWith('video/')) {
-    console.log('video')
-    try {
-      ffmpeg(path)
-        .screenshot({
-          timestamps: ['25%'],
-          filename: thumbnailFile,
-          folder: uploadDir,
-          size: '200x?',
-        })
-        .on('end', () => fs.promises.rename(`${thumbnailPath}.png`, thumbnailPath))
-      await fs.promises.rename(tmpPath, path)
-      thumbnail = thumbnailPath
-    } catch (error) {
-      try {
-        await fs.promises.unlink(tmpPath)
-        await fs.promises.unlink(path)
-      } catch (ignore) {}
-      throw error
-    }
+    const videoDetails = await processVideo(stream, path)
+    fileDetails = { ...fileDetails, ...videoDetails }
+  } else if (mimetype.startsWith('audio/')) {
+    const audioDetails = await processAudio(stream, path)
+    fileDetails = { ...fileDetails, ...audioDetails }
   } else {
-    console.log('no image')
-    try {
-      await fs.promises.rename(tmpPath, path)
-    } catch (error) {
-      try {
-        await fs.promises.unlink(tmpPath)
-        await fs.promises.unlink(path)
-      } catch (ignore) {}
-      throw error
-    }
+    await saveStreamToFile(stream, path)
   }
 
-  const { size } = await fs.promises.stat(path)
+  const { size } = await fs.promises.stat(filePath)
 
-  return {
-    path,
-    mimetype,
-    thumbnail,
-    filename,
-    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)
+  return { ...fileDetails, size }
 }

+ 119 - 0
backend/src/file/utils.ts

@@ -0,0 +1,119 @@
+import fs from 'fs'
+import sharp from 'sharp'
+import ffmpeg from 'fluent-ffmpeg'
+import { resizeOptions, thumbnailOptions } from './constants'
+
+export function saveStreamToFile(stream: any, path: [string, string]) {
+  const filePath = path.join('/')
+  return new Promise((resolve, reject) => {
+    const file = fs.createWriteStream(filePath)
+
+    file.on('finish', resolve)
+    file.on('error', (error) => {
+      fs.unlink(filePath, () => {
+        reject(error)
+      })
+    })
+    stream.on('error', (error: any) => {
+      file.destroy(error)
+      reject(error)
+    })
+    stream.pipe(file)
+  })
+}
+
+export async function imageResizeConvertJpg(inputPath: string, outputPath: string) {
+  const { maxWidth, maxHeight, options } = resizeOptions
+  await sharp(inputPath)
+    .resize(maxWidth, maxHeight, options)
+    .jpeg()
+    .toFile(outputPath)
+  const metadata = await sharp(outputPath).metadata()
+  return { width: metadata.width, height: metadata.height }
+}
+
+export function createThumbnail(image: string, thumbnail: string) {
+  const { maxWidth, maxHeight, options } = thumbnailOptions
+  return sharp(image)
+    .resize(maxWidth, maxHeight, options)
+    .jpeg()
+    .toFile(thumbnail)
+}
+
+export function videoScreenshot(video: string, path: [string, string]) {
+  const filePath = path.join('/')
+  const thumbnail = `${filePath}.thmb`
+  return new Promise((resolve, reject) => {
+    // ffmpeg(video)
+    //   .screenshot({ folder, filename: filename, ...screenshotOptions })
+    //   .on('end', () => {
+    //     fs.promises.rename(`${thumbnail}.png`, thumbnail)
+    //     resolve(thumbnail)
+    //   })
+    //   .on('error', (error) => reject(error))
+    ffmpeg(video)
+      .outputOption(
+        '-vf',
+        'fps=10,scale=200:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse',
+        '-loop',
+        '0',
+        '-t',
+        '3'
+      )
+      .save(thumbnail)
+      .on('end', () => resolve(thumbnail))
+      .on('error', (error) => reject(error))
+  })
+}
+
+export function mediumMetadata(medium: string): {} {
+  return new Promise((resolve, reject) => {
+    ffmpeg.ffprobe(medium, (error, metadata) => {
+      if (error) reject(error)
+      else {
+        const videoStreams = metadata.streams.filter((stream) => stream.codec_type === 'video')
+        const audioStreams = metadata.streams.filter((stream) => stream.codec_type === 'audio')
+        if (audioStreams.length > 0) {
+          const audioStream = audioStreams[0]
+          resolve({
+            duration: parseFloat(audioStream.duration || '0'),
+          })
+        } else if (videoStreams.length > 0) {
+          const videoStream = videoStreams[0]
+          resolve({
+            duration: parseFloat(videoStream.duration || '0'),
+            width: videoStream.width,
+            height: videoStream.height,
+          })
+        } else {
+          resolve({})
+        }
+      }
+    })
+  })
+}
+
+export async function processImage(stream: any, path: [string, string]) {
+  const filePath = path.join('/')
+  const thumbnail = `${filePath}.thmb`
+  const metadata = await imageResizeConvertJpg(stream, filePath)
+  await createThumbnail(filePath, thumbnail)
+  return { thumbnail, ...metadata }
+}
+
+export async function processVideo(stream: any, path: [string, string]) {
+  const [folder] = path
+  const filePath = path.join('/')
+  const thumbnail = `${filePath}.thmb`
+  await saveStreamToFile(stream, path)
+  await videoScreenshot(filePath, [folder, thumbnail])
+  const metadata = await mediumMetadata(filePath)
+  return { thumbnail, ...metadata }
+}
+
+export async function processAudio(stream: any, path: [string, string]) {
+  const filePath = path.join('/')
+  await saveStreamToFile(stream, path)
+  const metadata = await mediumMetadata(filePath)
+  return { ...metadata }
+}

Fichier diff supprimé car celui-ci est trop grand
+ 579 - 282
backend/src/gql/resolvers.ts


+ 0 - 1
backend/src/user/resolvers.ts

@@ -177,7 +177,6 @@ export const resolvers: IResolvers = {
  * @param permission - String or list of strings with required permissions
  */
 export function checkPermission(context: any, permission?: string | string[]) {
-  console.log(context.req.userId)
   if (!context.req.userId) throw new Error('Login required.')
   if (typeof permission === 'string') {
     if (!context.req.user.permissions.includes(permission))

+ 6 - 6
frontend/Dockerfile

@@ -4,13 +4,13 @@ WORKDIR /app
 
 ENV PATH /app/node_modules/.bin:$PATH
 
-COPY package.json /app/package.json
-
-RUN rm -rf node_modules/*
-RUN rm -rf node_modules/.bin
-RUN rm -rf node_modules/.cache
-RUN rm -f package-lock.json
 RUN npm install react-scripts -g --silent
+
+COPY package.json /app/package.json
+#RUN rm -rf node_modules/*
+#RUN rm -rf node_modules/.bin
+#RUN rm -rf node_modules/.cache
+#RUN rm -f package-lock.json
 #RUN npm install typescript @types/react --silent
 #RUN npm install --silent
 

+ 5 - 0
frontend/package-lock.json

@@ -8116,6 +8116,11 @@
         "flat-cache": "^2.0.1"
       }
     },
+    "filesize": {
+      "version": "6.1.0",
+      "resolved": "https://registry.npmjs.org/filesize/-/filesize-6.1.0.tgz",
+      "integrity": "sha512-LpCHtPQ3sFx67z+uh2HnSyWSLLu5Jxo21795uRDuar/EOuYWXib5EmPaGIBuSnRqH2IODiKA2k5re/K9OnN/Yg=="
+    },
     "fill-range": {
       "version": "7.0.1",
       "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",

+ 1 - 0
frontend/package.json

@@ -30,6 +30,7 @@
     "array-move": "^2.2.1",
     "date-fns": "^2.12.0",
     "dotenv": "^8.2.0",
+    "filesize": "^6.1.0",
     "formik": "2.1.4",
     "fuse.js": "5.1.0",
     "graphql": "15.0.0",

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

@@ -1,4 +1,4 @@
-import { useBlocksQuery } from '../../../src/gql'
+import { useBlocksQuery, BlocksQuery } from '../../../src/gql'
 import { FunctionComponent } from 'react'
 import { TBlock } from '../../../src/training/types'
 import { AdminList } from '../../../src/app'
@@ -42,8 +42,8 @@ const AdminBlocks = () => {
   const props = {
     name: 'Blocks',
     adminMenu: '/admin/block',
-    get: useBlocksQuery(),
-    dataKey: 'blocks',
+    get: useBlocksQuery,
+    dataKey: 'blocks' as keyof BlocksQuery,
     Component: AdminBlock,
   }
   return <AdminList {...props} />

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

@@ -1,4 +1,4 @@
-import { useExercisesQuery } from '../../../src/gql'
+import { useExercisesQuery, ExercisesQuery } from '../../../src/gql'
 import { FunctionComponent } from 'react'
 import { TExercise } from '../../../src/training/types'
 import { AdminList } from '../../../src/app'
@@ -42,8 +42,8 @@ const AdminExercises = () => {
   const props = {
     name: 'Exercises',
     adminMenu: '/admin/exercise',
-    get: useExercisesQuery(),
-    dataKey: 'exercises',
+    get: useExercisesQuery,
+    dataKey: 'exercises' as keyof ExercisesQuery,
     Component: AdminExercise,
   }
   return <AdminList {...props} />

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

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

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

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

+ 65 - 7
frontend/pages/admin/file/index.tsx

@@ -1,12 +1,70 @@
-import { UploadFile } from '../../../src/file'
-import { AdminPage } from '../../../src/app'
+import { useFilesQuery, File, useDeleteFileMutation, FilesQuery } from '../../../src/gql'
+import { FunctionComponent } from 'react'
+import { AdminList } from '../../../src/app'
+import theme from '../../../src/styles/theme'
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
+import { faLink } from '@fortawesome/free-solid-svg-icons'
+import filesize from 'filesize'
 
-const Files = () => {
+const AdminFile: FunctionComponent<{ item: Partial<File>; className?: string }> = ({
+  item,
+  className,
+}) => {
   return (
-    <AdminPage>
-      <UploadFile />
-    </AdminPage>
+    <div className={className}>
+      <div className='admin-file-title'>
+        <a href={`/${item.path}`}>
+          <FontAwesomeIcon icon={faLink} height={14} />
+          {' ' + item.filename}
+        </a>
+      </div>
+      <div className='admin-file-description'>{item.comment}</div>
+      <div className='admin-file-size'>{item.size ? filesize(item.size) : '?'}</div>
+      <div className='admin-file-type'>{item.mimetype}</div>
+      <div className='admin-file-mdate'>{new Date(item.updatedAt).toLocaleString()}</div>
+      <style jsx>{`
+        .${className} {
+          display: flex;
+        }
+        .admin-file-title {
+          width: 25%;
+        }
+        .admin-file-description {
+          width: 35%;
+        }
+        .admin-file-size {
+          width: 10%;
+        }
+        .admin-file-type {
+          width: 10%;
+        }
+        .admin-file-mdate {
+          width: 20%;
+        }
+        button {
+          margin: 0 0.4em;
+          padding: 0;
+          color: ${theme.colors.buttonBackground};
+          background-color: transparent;
+        }
+        button.false {
+          color: #5557;
+        }
+      `}</style>
+    </div>
   )
 }
 
-export default Files
+const AdminFiles = () => {
+  const props = {
+    name: 'Files',
+    adminMenu: '/admin/file',
+    get: useFilesQuery,
+    remove: useDeleteFileMutation,
+    dataKey: 'files' as keyof FilesQuery,
+    Component: AdminFile,
+  }
+  return <AdminList {...props} />
+}
+
+export default AdminFiles

+ 4 - 6
frontend/pages/admin/training/index.tsx

@@ -3,6 +3,7 @@ import {
   useDeleteTrainingMutation,
   TrainingsDocument,
   usePublishMutation,
+  TrainingsQuery,
 } from '../../../src/gql'
 import { FunctionComponent } from 'react'
 import { TTraining } from '../../../src/training/types'
@@ -70,12 +71,9 @@ const AdminTrainings = () => {
   const props = {
     name: 'Trainings',
     adminMenu: '/admin/training',
-    get: useTrainingsQuery(),
-    remove: useDeleteTrainingMutation({
-      refetchQueries: [{ query: TrainingsDocument }],
-      update: (args) => console.log(args),
-    }),
-    dataKey: 'trainings',
+    get: useTrainingsQuery,
+    remove: useDeleteTrainingMutation,
+    dataKey: 'trainings' as keyof TrainingsQuery,
     Component: AdminTraining,
   }
   return <AdminList {...props} />

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

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

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

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

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

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

+ 40 - 17
frontend/src/app/components/AdminList.tsx

@@ -2,30 +2,47 @@ import { FunctionComponent } from 'react'
 import AdminPage from './AdminPage'
 import Link from 'next/link'
 import theme from '../../styles/theme'
-import { QueryResult, MutationTuple } from '@apollo/client'
+import { QueryResult, MutationTuple, QueryHookOptions, MutationHookOptions } from '@apollo/client'
 import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
-import { faEdit, faTrash, faEye, faPlusCircle } from '@fortawesome/free-solid-svg-icons'
+import { faEdit, faTrash, faPlusCircle } from '@fortawesome/free-solid-svg-icons'
 
-interface IAdminList {
+interface IAdminList<TQueryData, TQueryVariables, TDeleteData, TDeleteVariables> {
   name: string
   adminMenu?: string
-  dataKey: string
-  get: QueryResult<any>
-  remove?: MutationTuple<any, any>
+  dataKey: keyof TQueryData
+  get: (
+    baseOptions?: QueryHookOptions<TQueryData, TQueryVariables>
+  ) => QueryResult<TQueryData, TQueryVariables>
+  remove?: (
+    baseOptions?: MutationHookOptions<TDeleteData, TDeleteVariables>
+  ) => MutationTuple<TDeleteData, TDeleteVariables>
   Component: FunctionComponent<any>
 }
 
-const AdminList = ({ name, adminMenu, dataKey, get, remove, Component }: IAdminList) => {
-  const [removeFunction, removeResult] = remove ?? [undefined, undefined]
+const AdminList = <
+  TQueryData extends { [dataKey: string]: any[] },
+  TQueryVariables,
+  TDeleteData,
+  TDeleteVariables
+>({
+  name,
+  adminMenu,
+  dataKey,
+  get,
+  remove,
+  Component,
+}: IAdminList<TQueryData, TQueryVariables, TDeleteData, TDeleteVariables>) => {
+  const [removeFunction, removeResult] = remove ? remove() : [undefined, undefined]
+  const { data, error, loading, refetch } = get({ fetchPolicy: 'network-only' })
 
   let content
-  if (get.loading) content = <p>Loading data...</p>
-  else if (get.error) content = <p>Error loading data.</p>
-  else if (!get.data) content = <p>No data found.</p>
+  if (loading) content = <p>Loading data...</p>
+  else if (error) content = <p>Error loading data.</p>
+  else if (!data) content = <p>No data found.</p>
   else
     content = (
       <ul>
-        {get.data[dataKey].map((item: any) => (
+        {data[dataKey].map((item: any) => (
           <li key={item.id}>
             <Component key={item.id} item={item} className='admin-component' />
 
@@ -37,12 +54,13 @@ const AdminList = ({ name, adminMenu, dataKey, get, remove, Component }: IAdminL
                   </button>
                 </a>
               </Link>
-              {removeFunction && removeResult && (
+              {removeFunction && (
                 <button
-                  onClick={(event) => {
-                    removeFunction()
+                  onClick={async (event) => {
+                    const deletedItem = await removeFunction({ variables: { id: item.id } } as any)
+                    if (deletedItem) refetch()
                   }}
-                  disabled={removeResult.loading}
+                  disabled={removeResult?.loading}
                   title='delete'
                 >
                   <FontAwesomeIcon icon={faTrash} height={16} />
@@ -69,7 +87,7 @@ const AdminList = ({ name, adminMenu, dataKey, get, remove, Component }: IAdminL
             border-bottom: 1px solid #0002;
           }
           ul :global(li:hover) {
-            background-color: ${theme.colors.formHighlightBackground}44;
+            background-color: ${theme.colors.formHighlightBackground}22;
           }
           ul :global(.admin-component) {
             flex-grow: 1;
@@ -82,6 +100,10 @@ const AdminList = ({ name, adminMenu, dataKey, get, remove, Component }: IAdminL
           }
 
           ul :global(a) {
+            color: ${theme.colors.highlight};
+            text-decoration: none;
+          }
+          ul :global(button > a) {
             color: ${theme.colors.button};
             text-decoration: none;
           }
@@ -92,6 +114,7 @@ const AdminList = ({ name, adminMenu, dataKey, get, remove, Component }: IAdminL
             right: 0;
             width: 80px;
             background-color: ${theme.colors.background};
+            box-shadow: ${theme.bsSmall};
           }
           ul :global(li:hover .admin-toolbar) {
             display: flex;

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

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

+ 1 - 1
frontend/src/timer/components/AudioPlayer.tsx → frontend/src/audioplayer/components/AudioPlayer.tsx

@@ -4,7 +4,7 @@ import { Howl } from 'howler'
 const AudioPlayer = ({ setAudio }: { setAudio: any }) => {
   useEffect(() => {
     const sound = new Howl({
-      src: ['/media/06_Better_As_One.mp3']
+      src: ['/media/06_Better_As_One.mp3'],
     })
     console.log(sound)
     setAudio(sound)

+ 31 - 0
frontend/src/audioplayer/components/PlayButton.tsx

@@ -0,0 +1,31 @@
+import { useEffect, useRef, useState } from 'react'
+import { Howl } from 'howler'
+
+const PlayButton = ({ url }: { url?: string }) => {
+  const player = useRef<Howl>()
+  const [state, setState] = useState(player.current?.playing())
+
+  useEffect(() => {
+    if (!url) return
+    const absUrl = `/${url}`
+    console.log(absUrl)
+    player.current = new Howl({
+      src: [absUrl],
+      format: 'mp3',
+    })
+  }, [])
+
+  return (
+    <button
+      onClick={() => {
+        if (state) player.current?.pause()
+        else player.current?.play()
+        setState(!state)
+      }}
+    >
+      {state ? 'Stop!' : 'Play!'}
+    </button>
+  )
+}
+
+export default PlayButton

+ 4 - 0
frontend/src/audioplayer/index.ts

@@ -0,0 +1,4 @@
+import AudioPlayer from './components/AudioPlayer'
+import PlayButton from './components/PlayButton'
+
+export { AudioPlayer, PlayButton }

+ 59 - 0
frontend/src/file/api.tsx

@@ -0,0 +1,59 @@
+export const SpotifyPlay = ({
+  url,
+  width = 250,
+  height = 80,
+}: {
+  url: string
+  width?: number
+  height?: number
+}) => {
+  let trackId
+  const shared = url.match(/\/track\/([a-zA-Z0-9]+)/)
+  if (shared) trackId = shared[1]
+
+  if (!trackId) return null
+  const src = `https://open.spotify.com/embed/track/${trackId}`
+  return (
+    <iframe
+      src={src}
+      width={width}
+      height={height}
+      frameBorder='0'
+      allowTransparency={true}
+      allow='encrypted-media'
+    />
+  )
+}
+
+export const YoutubeThumbnail = (vid: string) => {
+  return `https://www.youtube-nocookie.com/embed/${vid}?rel=0`
+}
+
+export const Youtube = ({
+  url,
+  width = 320,
+  height = 240,
+}: {
+  url: string
+  width?: number
+  height?: number
+}) => {
+  let vid
+  const webplayer = url.match(/watch\?v=([a-zA-Z0-9\-_]+)/)
+  if (webplayer) vid = webplayer[1]
+  const shorturl = url.match(/youtu\.be\/([a-zA-Z0-9\-_]+)/)
+  if (shorturl) vid = shorturl[1]
+
+  if (!vid) return null
+  const src = `http://www.youtube.com/embed/${vid}?autoplay=1`
+  return (
+    <iframe
+      src={src}
+      width={width}
+      height={height}
+      frameBorder='0'
+      allowTransparency={true}
+      allowFullScreen={true}
+    />
+  )
+}

+ 68 - 0
frontend/src/file/components/EditFile.tsx

@@ -0,0 +1,68 @@
+import { TextInput, useForm } from '../../form'
+import { prepareDataForDB } from '../../training/utils'
+import { File, useUpdateFileMutation, useUploadFileMutation } from '../../gql'
+import { customEvent } from '../../lib/customEvent'
+import { ChangeEvent, useState } from 'react'
+import { useRouter } from 'next/router'
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
+import { faLink } from '@fortawesome/free-solid-svg-icons'
+import { PlayButton } from '../../audioplayer'
+
+const EditFile = ({ file }: { file?: Partial<File> }) => {
+  const router = useRouter()
+  const { values, onChange } = useForm<Partial<File> & { file: any }>({ file: undefined, ...file })
+  console.log(file)
+  const [type, setType] = useState<undefined | string>()
+  const [uploadFile, createData] = useUploadFileMutation()
+  const [updateFile, updateData] = useUpdateFileMutation()
+
+  function handleFile(event: ChangeEvent<HTMLInputElement>) {
+    const loadedFile = event.target.files?.item(0)
+    if (!loadedFile) return
+    setType(loadedFile.type)
+    onChange(customEvent('file', loadedFile))
+  }
+
+  return (
+    <form
+      onSubmit={async (event) => {
+        event.preventDefault()
+        const { file: loadedFile, ...formData } = values
+        const data = prepareDataForDB(formData, file)
+        console.log(data, file)
+        if (file) {
+          const { id, ...others } = data
+          updateFile({ variables: { data: others, where: { id: file.id } } })
+        } else {
+          const uploadResult = await uploadFile({ variables: { ...values, file: loadedFile } })
+          if (uploadResult) {
+            router.push(`/admin/video/[id]`, `/admin/video/${uploadResult.data?.createFile.id}`)
+          }
+        }
+      }}
+    >
+      {file ? (
+        <>
+          <a href={`/${values.path}`} download={values.filename}>
+            <FontAwesomeIcon icon={faLink} height={14} />
+            {' ' + values.filename}
+          </a>
+          {file.mimetype?.startsWith('audio') && <PlayButton url={values.path} />}
+        </>
+      ) : (
+        <input name='file' type='file' multiple={false} onChange={handleFile} />
+      )}
+      <TextInput name='comment' value={values.comment} placeholder='Comment' onChange={onChange} />
+      {(type?.startsWith('audio') || file?.mimetype?.startsWith('audio')) && (
+        <>
+          <TextInput name='artist' value={values.artist} placeholder='Artist' onChange={onChange} />
+          <TextInput name='title' value={values.title} placeholder='Title' onChange={onChange} />
+        </>
+      )}
+      {type}
+      <button type='submit'>Save file</button>
+    </form>
+  )
+}
+
+export default EditFile

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

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

+ 14 - 7
frontend/src/file/components/VideoSelector.tsx

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

+ 45 - 81
frontend/src/file/file.graphql

@@ -10,6 +10,27 @@ query fsFiles($directory: String!) {
   }
 }
 
+query file($where: FileWhereUniqueInput!) {
+  file(where: $where) {
+    id
+    path
+    mimetype
+    user {
+      id
+      name
+    }
+    thumbnail
+    filename
+    size
+    updatedAt
+    comment
+    title
+    artist
+    width
+    height
+  }
+}
+
 query files(
   $where: FileWhereInput
   $orderBy: FileOrderByInput
@@ -40,11 +61,28 @@ query files(
     size
     updatedAt
     comment
+    title
   }
 }
 
-mutation uploadFile($file: Upload!, $comment: String) {
-  uploadFile(file: $file, comment: $comment) {
+mutation uploadFile(
+  $file: Upload!
+  $comment: String
+  $width: Int
+  $height: Int
+  $duration: Float
+  $title: String
+  $artist: String
+) {
+  uploadFile(
+    file: $file
+    comment: $comment
+    width: $width
+    height: $height
+    duration: $duration
+    title: $title
+    artist: $artist
+  ) {
     id
     path
     mimetype
@@ -52,93 +90,19 @@ mutation uploadFile($file: Upload!, $comment: String) {
     filename
     size
     comment
-  }
-}
-
-query videos {
-  videos {
-    id
-    title
-    description
-    duration
-    file {
-      id
-      path
-      size
-      thumbnail
-      comment
-    }
-    link
-  }
-}
-
-query video($where: VideoWhereUniqueInput!) {
-  video(where: $where) {
-    id
     title
-    description
-    duration
-    file {
-      id
-      path
-      size
-      thumbnail
-      comment
-    }
-  }
-}
-
-mutation createVideo($data: VideoCreateInput) {
-  createVideo(data: $data) {
-    id
-  }
-}
-
-mutation updateVideo($data: VideoUpdateInput!, $where: VideoWhereUniqueInput!) {
-  updateVideo(data: $data, where: $where) {
-    id
-  }
-}
-
-query pictures {
-  pictures {
-    id
-    title
-    description
-    file {
-      id
-      path
-      size
-      thumbnail
-      comment
-    }
-    link
-  }
-}
-
-query picture($where: VideoWhereUniqueInput!) {
-  picture(where: $where) {
-    id
-    title
-    description
-    file {
-      id
-      path
-      size
-      thumbnail
-      comment
-    }
+    artist
   }
 }
 
-mutation createPicture($data: PictureCreateInput) {
-  createPicture(data: $data) {
+mutation updateFile($where: FileWhereUniqueInput!, $data: FileUpdateInput!) {
+  updateFile(where: $where, data: $data) {
     id
   }
 }
 
-mutation updatePicture($data: PictureUpdateInput!, $where: PictureWhereUniqueInput!) {
-  updatePicture(data: $data, where: $where) {
+mutation deleteFile($id: ID!) {
+  deleteFile(id: $id) {
     id
   }
 }

Fichier diff supprimé car celui-ci est trop grand
+ 579 - 283
frontend/src/gql/index.tsx


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

@@ -20,7 +20,7 @@ const BlockInstanceInputs = ({
   className?: string
 }) => {
   const [show, setShow] = useState(true)
-  const [source, setSource] = useState<'new' | 'existing'>()
+  const [source, setSource] = useState<'new' | 'existing'>('new')
   const [newItem, setNewItem] = useState(emptyBlock())
   const [existingItem, setExistingItem] = useState<undefined | TBlock>()
 

+ 2 - 7
frontend/src/training/components/BlockList.tsx

@@ -4,6 +4,7 @@ import { SortableList } from '../../sortable'
 import { TBlockInstance } from '../types'
 import { emptyBlockInstance } from '../utils'
 import BlockInstanceInputs from './BlockInstanceInputs'
+import { customEvent } from '../../lib/customEvent'
 
 const BlockList = ({
   value = [],
@@ -94,11 +95,5 @@ export function addBlock(name: string, blockList: TBlockInstance[]) {
   const newBlock = emptyBlockInstance({
     order: blockList ? blockList.filter((block) => !block.id.startsWith('--')).length : 0,
   })
-  return {
-    target: {
-      type: 'custom',
-      name,
-      value: blockList ? [...blockList, newBlock] : [newBlock],
-    },
-  }
+  return customEvent(name, [...blockList, newBlock])
 }

+ 16 - 19
frontend/src/training/components/BlockSelector.tsx

@@ -24,25 +24,22 @@ const BlockSelector = ({ value, onChange, name = 'block', label = 'Block' }: IBl
   }
 
   return (
-    <>
-      <label>{label}</label>
-      <select id={name} name={name} value={value?.id} onChange={handleChange}>
-        {blocks.loading && 'loading blocks...'}
-        {blocks.error && 'error loading blocks'}
-        {blocks.data && (
-          <>
-            <option key='not-selected'>Please select a block.</option>
-            {blocks.data.blocks.map((block) => (
-              <option key={block.id} value={block.id}>
-                {[block.title, block.description?.slice(0, 60)]
-                  .filter((block) => !!block)
-                  .join(' - ')}
-              </option>
-            ))}
-          </>
-        )}
-      </select>
-    </>
+    <select id={name} name={name} value={value?.id} onChange={handleChange}>
+      {blocks.loading && 'loading blocks...'}
+      {blocks.error && 'error loading blocks'}
+      {blocks.data && (
+        <>
+          <option key='not-selected'>Please select a block.</option>
+          {blocks.data.blocks.map((block) => (
+            <option key={block.id} value={block.id}>
+              {[block.title, block.description?.slice(0, 60)]
+                .filter((block) => !!block)
+                .join(' - ')}
+            </option>
+          ))}
+        </>
+      )}
+    </select>
   )
 }
 

+ 5 - 6
frontend/src/training/components/EditTraining.tsx

@@ -1,15 +1,14 @@
-import { useCreateTrainingMutation, useUpdateTrainingMutation } from '../../gql'
+import { useCreateTrainingMutation, useUpdateTrainingMutation, Training } from '../../gql'
 import { useForm, TextInput, DateTimeInput, Checkbox } from '../../form'
-import { emptyTraining, prepareDataForDB } from '../utils'
+import { prepareDataForDB } from '../utils'
 import TrainingTypeSelector from './TrainingTypeSelector'
-import { TTraining } from '../types'
 import Registrations from './Registrations'
 import Ratings from './Ratings'
 import BlockList from './BlockList'
 import theme from '../../styles/theme'
 
-const EditTraining = ({ training }: { training?: TTraining }) => {
-  const { values, touched, onChange, loadData } = useForm(training || emptyTraining())
+const EditTraining = ({ training }: { training?: Partial<Training> }) => {
+  const { values, onChange } = useForm(training || ({} as Partial<Training>))
   const [createTraining, createData] = useCreateTrainingMutation()
   const [updateTraining, updateData] = useUpdateTrainingMutation()
 
@@ -36,7 +35,7 @@ const EditTraining = ({ training }: { training?: TTraining }) => {
       }}
     >
       <fieldset className='fields-training'>
-        {!values.id.startsWith('++') && (
+        {!values.id?.startsWith('++') && (
           <div className='training-info'>
             <div>Training ID: {values.id}</div>
             <div>Created at: {values.createdAt}</div>

+ 3 - 9
frontend/src/training/training.graphql

@@ -43,11 +43,6 @@ query trainings(
   $first: Int
   $last: Int
 ) {
-  count: trainingsCount(where: $where) {
-    aggregate {
-      count
-    }
-  }
   trainings(
     where: $where
     orderBy: $orderBy
@@ -165,11 +160,9 @@ fragment exerciseContent on Exercise {
   description
   videos {
     id
-    title
   }
   pictures {
     id
-    title
   }
   targets
   baseExercise
@@ -181,11 +174,12 @@ fragment blockWithoutBlocks on Block {
   description
   videos {
     id
-    title
   }
   pictures {
     id
-    title
+  }
+  tracks {
+    id
   }
   duration
   format {

+ 2 - 1
frontend/src/training/utils.ts

@@ -119,9 +119,10 @@ export function emptyBlock(input?: Partial<Block>) {
 }
 
 export function emptyBlockInstance(input?: Partial<BlockInstance>) {
-  const emptyBlockInstance: TBlockInstance = {
+  const emptyBlockInstance: Partial<BlockInstance> = {
     id: randomID(),
     order: 0,
+    block: emptyBlock(),
   }
   return { ...emptyBlockInstance, ...input }
 }

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff