Browse Source

Merge branch 'feature/v8-5397-upload-image' into develop

Olivier Massot 1 year ago
parent
commit
a0d6b6f6b5

+ 13 - 11
components/Ui/Image.vue

@@ -6,7 +6,7 @@ Permet d'afficher une image par défaut si l'image demandée n'est pas disponibl
   <main>
     <div class="image-wrapper" :style="{width: width + 'px'}">
       <v-img
-        :src="imageSrc"
+        :src="imageSrc ?? undefined"
         :lazy-src="defaultImagePath"
         :height="height"
         :width="width"
@@ -37,6 +37,8 @@ Permet d'afficher une image par défaut si l'image demandée n'est pas disponibl
 <script setup lang="ts">
 import {useImageFetch} from "~/composables/data/useImageFetch";
 import ImageManager from "~/services/data/imageManager";
+import type {WatchStopHandle} from "@vue/runtime-core";
+import type {Ref} from "@vue/reactivity";
 
 const props = defineProps({
   /**
@@ -82,24 +84,24 @@ const { fetch } = useImageFetch()
 
 const defaultImagePath = props.defaultImage ?? ImageManager.defaultImage
 
-const { data: imageSrc, pending, refresh: refreshImage } = fetch(props.imageId ?? null, defaultImagePath, props.height, props.width) as any
-
-
 const emit = defineEmits(['overlay-clicked'])
 
-/**
- * Si l'id change, on recharge l'image
- */
-const unwatch: WatchStopHandle = watch(() => props.imageId, async () => {
-  await refreshImage()
-})
+const fileId = toRef(props, 'imageId')
+
+const { data: imageSrc, pending, refresh: refreshImage } = await fetch(fileId, defaultImagePath, props.height, props.width) as any
 
 const refresh = () => {
   refreshImage()
 }
-
 defineExpose({ refresh })
 
+/**
+ * Si l'id change, on recharge l'image
+ */
+const unwatch: WatchStopHandle = watch(() => props.imageId, async (value, oldValue) => {
+  refresh()
+})
+
 /**
  * Lorsqu'on démonte le component, on supprime le watcher
  */

+ 113 - 106
components/Ui/Input/Image.vue

@@ -6,7 +6,7 @@ Assistant de création d'image
 <template>
   <div class="input-image" >
     <UiImage
-        ref="imageElement"
+        ref="uiImage"
         :image-id="modelValue"
         :default-image="defaultImage"
         :width="width"
@@ -39,14 +39,14 @@ Assistant de création d'image
                 ref="cropper"
                 class="upload__cropper"
                 check-orientation
-                :src="uploadedImage.src"
+                :src="currentImage.src"
                 :default-position="defaultPosition"
                 :default-size="defaultSize"
                 @change="onCropperChange"
               />
 
               <div
-                  v-if="uploadedImage.src"
+                  v-if="currentImage.src"
                   class="upload__reset-button"
                   title="Reset Image"
                   @click="reset()"
@@ -54,12 +54,14 @@ Assistant de création d'image
                 <v-icon>fas fa-trash</v-icon>
               </div>
             </div>
+
             <div class="upload__buttons-wrapper">
               <button class="upload__button" @click="fileInput?.click()">
                 <input ref="fileInput" type="file" accept="image/*" @change="uploadImage($event)" />
                 {{$t('upload_image')}}
               </button>
             </div>
+
             <span class="max-size-label">{{ $t('max_size_4_mb') }}</span>
           </div>
 
@@ -78,18 +80,16 @@ Assistant de création d'image
 </template>
 
 <script setup lang="ts">
-
-import {ref} from "@vue/reactivity";
+import { Cropper } from 'vue-advanced-cropper';
+import 'vue-advanced-cropper/dist/style.css';
+import {type Ref, ref} from "@vue/reactivity";
 import File from '~/models/Core/File'
 import type {PropType} from "@vue/runtime-core";
 import {useEntityManager} from "~/composables/data/useEntityManager";
-import type {AnyJson} from "~/types/data";
-import ApiResource from "~/models/ApiResource";
 import {useImageManager} from "~/composables/data/useImageManager";
-import type { Cropper } from 'vue-advanced-cropper'
-import 'vue-advanced-cropper/dist/style.css';
-import {FILE_FOLDER, FILE_STATUS, FILE_TYPE, FILE_VISIBILITY, TYPE_ALERT} from "~/types/enum/enums";
+import {FILE_VISIBILITY, TYPE_ALERT} from "~/types/enum/enums";
 import {usePageStore} from "~/stores/page";
+import ImageUtils from "~/services/utils/imageUtils";
 
 const props = defineProps({
   /**
@@ -143,24 +143,14 @@ const { em } = useEntityManager()
 const { imageManager } = useImageManager()
 const pageStore = usePageStore()
 
-const emit = defineEmits(['update:modelValue', 'update:image', 'reset'])
+const emit = defineEmits(['update:modelValue'])
 
 /**
  * Références à des composants
  */
 const fileInput: Ref<null | any> = ref(null)
 const cropper: Ref<any> = ref(null)
-const imageElement: Ref<any> = ref(null)
-
-/**
- * L'objet File contenant les informations de l'image
- */
-const file: Ref<ApiResource | null> = ref(null)
-
-/**
- * L'image elle-même
- */
-const image: Ref<ArrayBuffer | string | null> = ref(null)
+const uiImage: Ref<any> = ref(null)
 
 /**
  * L'objet File ou l'image sont en cours de chargement
@@ -172,28 +162,38 @@ const pending: Ref<boolean> = ref(false)
  */
 const showModal = ref(false)
 
+/**
+ * L'objet File contenant les informations de l'image
+ */
+const file: Ref<File | null> = ref(null)
+
 /**
  * Données d'une nouvelle image uploadée par l'utilisateur
  */
-const uploadedImage: Ref<AnyJson> = ref({
+const currentImage: Ref<
+    {id: string | number | null, src: string | null, content: string | null, name: string | null}
+> = ref({
   id: null,
   src: null,
-  file: null,
+  content: null,
   name: null
 })
 
+/**
+ * Taille maximale autorisée pour les images uploadées (en bytes)
+ */
 const MAX_FILE_SIZE = 4 * 1024 * 1024
 
 /**
  * Coordonnées du cropper
  */
-const coordinates: Ref<{ left?: number, top?: number, height?: number, width?: number }> = ref({})
+const cropperConfig: Ref<{ left?: number, top?: number, height?: number, width?: number }> = ref({})
 
 /**
  * @see https://advanced-cropper.github.io/vue-advanced-cropper/components/cropper.html#defaultposition
  */
 const defaultPosition = () => {
-  return { left : coordinates.value.left, top : coordinates.value.top }
+  return { left : cropperConfig.value.left, top : cropperConfig.value.top }
 }
 
 /**
@@ -206,11 +206,32 @@ const defaultSize = (params: any): { width: number, height: number } | null => {
   const { imageSize, visibleArea } = params
 
   return {
-    width: coordinates.value.width ?? (visibleArea || imageSize).width,
-    height: coordinates.value.height ?? (visibleArea || imageSize).height
+    width: cropperConfig.value.width ?? (visibleArea || imageSize).width,
+    height: cropperConfig.value.height ?? (visibleArea || imageSize).height
   }
 }
 
+/**
+ * Charge l'image correspondant au fichier dans le cropper
+ *
+ * @param fileId
+ */
+const loadImage = async (fileId: number) => {
+  file.value = await em.fetch(File, fileId) as File
+
+  if (file.value.config) {
+    const fileConfig = JSON.parse(file.value.config)
+    cropperConfig.value.left = fileConfig.x
+    cropperConfig.value.top = fileConfig.y
+    cropperConfig.value.height = fileConfig.height
+    cropperConfig.value.width = fileConfig.width
+  }
+
+  currentImage.value.name = file.value.name
+  currentImage.value.id = file.value.id
+  currentImage.value.src = await imageManager.get(fileId) as string
+}
+
 /**
  * Affiche la modale d'upload / modification de l'image
  */
@@ -220,45 +241,38 @@ const openModal = async () => {
 
   if (props.modelValue !== null) {
     // Un objet File existe déjà: on le récupère
-    file.value = await em.fetch(File, props.modelValue)
-    image.value = await imageManager.get(props.modelValue)
-
-    if (file.value.config) {
-      const config = JSON.parse(file.value.config)
-      coordinates.value.left = config.x
-      coordinates.value.top = config.y
-      coordinates.value.height = config.height
-      coordinates.value.width = config.width
-    }
-
-    uploadedImage.value.name = file.value.name
-    uploadedImage.value.id = file.value.id
-    uploadedImage.value.src = image.value
+    await loadImage(props.modelValue)
 
   } else {
     // Nouveau File
-    file.value = em.newInstance(File)
+    file.value = em.newInstance(File) as File
   }
 
   pending.value = false
 }
 
 /**
- * Réinitialise l'image sélectionnée
+ * Réinitialise l'image actuellement chargée dans le cropper
  */
 const reset = () => {
-  uploadedImage.value.src = null
-  uploadedImage.value.file = null
-  uploadedImage.value.name = null
-  uploadedImage.value.id = null
-  URL.revokeObjectURL(uploadedImage.value.src)
+  if (currentImage.value.src !== null) {
+    URL.revokeObjectURL(currentImage.value.src)
+  }
+
+  currentImage.value = {
+    src: null, content: null, name: null, id: null
+  }
+
+  cropperConfig.value = {
+    left: undefined, height: undefined, top: undefined, width: undefined
+  }
 }
 
 /**
  * Upload une image depuis le poste client
  * @param event
  */
-const uploadImage = (event: any) => {
+const uploadImage = async (event: any) => {
   const { files } = event.target
 
   if (!files || !files[0]) {
@@ -272,16 +286,17 @@ const uploadImage = (event: any) => {
     return
   }
 
-  reset()
-
-  uploadedImage.value.name = files[0].name
-  uploadedImage.value.src = URL.createObjectURL(files[0])
-  uploadedImage.value.file = files[0]
-
-  coordinates.value.top = 0
-  coordinates.value.left = 0
-  coordinates.value.height = uploadedImage.value.height
-  coordinates.value.width = uploadedImage.value.width
+  // Met à jour l'image dans le cropper
+  currentImage.value.id = null
+  currentImage.value.name = uploadedFile.name
+  currentImage.value.src = URL.createObjectURL(uploadedFile)
+  currentImage.value.content = await ImageUtils.blobToBase64(uploadedFile)
+
+  // Met à jour la configuration du cropper
+  cropperConfig.value.top = 0
+  cropperConfig.value.left = 0
+  cropperConfig.value.height = uploadedFile.height
+  cropperConfig.value.width = uploadedFile.width
 }
 
 /**
@@ -289,7 +304,7 @@ const uploadImage = (event: any) => {
  * @param newCoordinates
  */
 const onCropperChange = ({ coordinates: newCoordinates } : any) => {
-  coordinates.value = newCoordinates;
+  cropperConfig.value = newCoordinates;
 }
 
 /**
@@ -301,46 +316,34 @@ const cancel = () => {
 }
 
 /**
- * Construit la nouvelle config du fichier à partir des réglages actuels
- */
-const updateFileConfig = () => {
-  file.value!!.config = JSON.stringify({
-    x: coordinates.value.left,
-    y: coordinates.value.top,
-    height: coordinates.value.height,
-    width: coordinates.value.width
-  })
-}
-
-/**
- * Enregistre une nouvelle image
+ * Enregistre une nouvelle image et retourne l'id du fichier nouvellement créé
  */
-const saveNewImage = async () => {
+const saveNewImage = async (): Promise<number> => {
   if (!file.value) {
     throw new Error('No File object defined')
   }
-
-  // On créé l'objet File à sauvegarder
-  file.value.name = uploadedImage.value.name
-  file.value.imgFieldName = props.field
-  file.value.visibility = FILE_VISIBILITY.EVERYBODY
-  file.value.folder = FILE_FOLDER.IMAGES
-  file.value.status = FILE_STATUS.READY
-  file.value.type = FILE_TYPE.UPLOADED
-
-  updateFileConfig()
-
-  if (props.ownerId) {
-    // TODO: revoir
-    file.value.ownerId = props.ownerId
+  if (!currentImage.value.name) {
+    throw new Error("Missing file's name")
+  }
+  if (!currentImage.value.content) {
+    throw new Error("Missing file's content")
   }
 
-  // TODO: A revoir, on doit pouvoir persister l'image aussi
-  const returnedFile = await em.persist(File, file.value)
-  // await imageManager.persist(file.value, uploadedImage.src)
+  const config = JSON.stringify({
+    x: cropperConfig.value.left,
+    y: cropperConfig.value.top,
+    height: cropperConfig.value.height,
+    width: cropperConfig.value.width
+  })
+
+  const response = await imageManager.upload(
+      currentImage.value.name,
+      currentImage.value.content,
+      FILE_VISIBILITY.EVERYBODY,
+      config
+  ) as any
 
-  //On émet un évent afin de mettre à jour le formulaire de départ
-  emit('update:modelValue', returnedFile.id)
+  return response.fileId
 }
 
 /**
@@ -351,9 +354,14 @@ const saveExistingImage = async () => {
     throw new Error('No File object defined')
   }
 
-  updateFileConfig()
+  file.value.config = JSON.stringify({
+    x: cropperConfig.value.left,
+    y: cropperConfig.value.top,
+    height: cropperConfig.value.height,
+    width: cropperConfig.value.width
+  })
 
-  await em.persist(File, file.value) // TODO: à revoir
+  await em.persist(File, file.value)
 }
 
 /**
@@ -361,24 +369,23 @@ const saveExistingImage = async () => {
  */
 // TODO: Voir si tout ou partie de cette fonction peut passer dans le useImageFetch, imageManager ou imageUtils
 const save = async () => {
-  if (!file.value) {
-    throw new Error('No File object defined')
-  }
   pageStore.loading = true
 
-  if (uploadedImage.value.src && uploadedImage.value.src !== image.value) {
+  if (currentImage.value.src && currentImage.value.id === null) {
     // Une nouvelle image a été uploadée
-    await saveNewImage()
-  } else if (uploadedImage.value.id) {
+    const fileId = await saveNewImage()
+    emit('update:modelValue', fileId)
+
+  } else if (currentImage.value.id) {
     // L'image existante a été modifiée
     await saveExistingImage()
+    uiImage.value.refresh()
+
   } else {
     // On a reset l'image
     emit('update:modelValue', null)
   }
 
-  imageElement.value.refresh()
-
   showModal.value = false
   pageStore.loading = false
 }
@@ -387,8 +394,8 @@ const save = async () => {
  * Lorsqu'on démonte le component on supprime le watcher et on revoke l'objet URL
  */
 onUnmounted(() => {
-  if (uploadedImage.value && uploadedImage.value.src) {
-    URL.revokeObjectURL(uploadedImage.value.src)
+  if (currentImage.value && currentImage.value.src) {
+    URL.revokeObjectURL(currentImage.value.src)
   }
 })
 </script>

+ 9 - 6
composables/data/useImageFetch.ts

@@ -1,8 +1,10 @@
 import {useImageManager} from "~/composables/data/useImageManager";
-import type {FetchResult} from "#app";
+import type {AsyncData, FetchResult} from "#app";
+import {v4 as uuid4} from "uuid";
+import type {Ref} from "@vue/reactivity";
 
 interface useImageFetchReturnType {
-    fetch: (id: number | null, defaultImage?: string | null, height?: number, width?: number) => AsyncData<string | ArrayBuffer | null, Error | null>
+    fetch: (id: Ref<number | null>, defaultImage?: string | null, height?: number, width?: number) => AsyncData<string | ArrayBuffer | null, Error | null>
 }
 
 /**
@@ -12,14 +14,15 @@ export const useImageFetch = (): useImageFetchReturnType => {
     const { imageManager } = useImageManager()
 
     const fetch = (
-        id: number | null,  // If id is null, fetch shall return the default image url
+        id: Ref<number | null>,  // If id is null, fetch shall return the default image url
         defaultImage: string | null = null,
         height: number = 0,
         width: number = 0
     ) => useAsyncData(
-        'img' + (id ?? defaultImage ?? 0),
-        () => imageManager.get(id, defaultImage, height, width),
-        { lazy: true, server: false }  // Always fetch images client-side
+        'img' + (id ?? defaultImage ?? 0) + '_' + uuid4(),
+        () => imageManager.get(id.value, defaultImage, height, width),
+        { lazy: true, server: false },  // Always fetch images client-side
+
     )
 
     return { fetch }

+ 3 - 0
icon.svg

@@ -0,0 +1,3 @@
+<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M281.44 397.667H438.32C443.326 397.667 448.118 395.908 452.453 393.427C456.789 390.946 461.258 387.831 463.76 383.533C466.262 379.236 468.002 374.36 468 369.399C467.998 364.437 466.266 359.563 463.76 355.268L357.76 172.947C355.258 168.65 352.201 165.534 347.867 163.053C343.532 160.573 337.325 158.813 332.32 158.813C327.315 158.813 322.521 160.573 318.187 163.053C313.852 165.534 310.795 168.65 308.293 172.947L281.44 219.587L227.733 129.13C225.229 124.834 222.176 120.307 217.84 117.827C213.504 115.346 208.713 115 203.707 115C198.701 115 193.909 115.346 189.573 117.827C185.238 120.307 180.771 124.834 178.267 129.13L46.8267 355.268C44.3208 359.563 44.0022 364.437 44 369.399C43.9978 374.36 44.3246 379.235 46.8267 383.533C49.3288 387.83 53.7979 390.946 58.1333 393.427C62.4688 395.908 67.2603 397.667 72.2667 397.667H171.2C210.401 397.667 238.934 380.082 258.827 346.787L306.88 263.4L332.32 219.587L410.053 352.44H306.88L281.44 397.667ZM169.787 352.44H100.533L203.707 174.36L256 263.4L221.361 323.784C208.151 345.387 193.089 352.44 169.787 352.44Z" fill="#00DC82"/>
+</svg>

+ 3 - 1
lang/fr.json

@@ -670,5 +670,7 @@
   "max_size_4_mb": "Taille maximum: 4 MO",
   "file_too_large": "Le fichier est trop volumineux",
   "cycles_breadcrumbs": "Enseignements",
-  "cmf_licence_structure_breadcrumbs": "Licence CMF - Structure"
+  "cmf_licence_structure_breadcrumbs": "Licence CMF - Structure",
+  "no_recorded_subdomain": "Aucun sous-domaine enregistré",
+  "no_admin_access_recorded": "Aucun compte super-admin enregistré"
 }

+ 1 - 2
models/Organization/Parameters.ts

@@ -1,7 +1,6 @@
 import ApiModel from '~/models/ApiModel'
 import { Bool, Num, Str, Uid, Attr } from 'pinia-orm/dist/decorators'
 import Access from "~/models/Access/Access";
-import ApiResource from "~/models/ApiResource";
 import {IriEncoded} from "~/models/decorators";
 import File from "~/models/Core/File";
 
@@ -104,7 +103,7 @@ export default class Parameters extends ApiModel {
   @Bool(false, { notNullable: true })
   declare studentsAreAdherents: boolean
 
-  @Str(null)
+  @Attr(null)
   @IriEncoded(File)
   declare qrCode: number | null
 

+ 1 - 1
pages/parameters/general_parameters.vue

@@ -69,7 +69,7 @@
               v-if="organizationProfile.isCMFCentralService"
               class="d-flex flex-column"
           >
-            <span class="mb-1">{{ $t('qrCode')}} </span>
+            <span class="mb-1 v-label" style="font-size: 12px;">{{ $t('qrCode')}} </span>
             <UiInputImage
                 v-model="parameters.qrCode"
                 field="qrCode"

+ 2 - 2
pages/parameters/super_admin.vue

@@ -11,7 +11,7 @@
 
       <UiLoadingPanel v-if="pending"/>
       <UiForm
-          v-else
+          v-else-if="adminAccess"
           ref="form"
           :model="AdminAccess"
           :entity="adminAccess"
@@ -35,6 +35,7 @@
             variant="underlined"
         />
       </UiForm>
+      <span v-else>{{ $t('no_admin_access_recorded') }}</span>
   </div>
 </template>
 
@@ -57,7 +58,6 @@ const i18n = useI18n()
 
 const validationUtils = useValidationUtils()
 
-
 const rules = () => [
   (email: string | null) =>
     (email && validationUtils.validEmail(email)) || i18n.t('email_error')

+ 2 - 1
pages/parameters/website.vue

@@ -43,7 +43,7 @@
             <div><h4>{{ $t('your_subdomains') }} : </h4></div>
             <UiLoadingPanel v-if="subdomainsPending" />
             <div v-else>
-              <v-table class="subdomains-table my-2">
+              <v-table v-if="subdomains" class="subdomains-table my-2">
                 <tbody>
                   <tr
                       v-for="subdomain in subdomains.items"
@@ -66,6 +66,7 @@
                 </tbody>
 
               </v-table>
+              <span v-else>{{ $t('no_recorded_subdomain') }}</span>
 
               <div class="d-flex flex-row justify-center w-100">
                 <v-btn

+ 22 - 0
services/data/imageManager.ts

@@ -1,5 +1,7 @@
 import ApiRequestService from "./apiRequestService";
 import ImageUtils from "~/services/utils/imageUtils";
+import File from '~/models/Core/File'
+import {FILE_FOLDER, FILE_TYPE, FILE_VISIBILITY} from "~/types/enum/enums";
 
 /**
  * Permet le requêtage, l'upload et la manipulation des images via l'API Opentalent
@@ -59,6 +61,26 @@ class ImageManager {
         return await this.toBase64(response)
     }
 
+    public async upload(
+        filename: string,
+        content: string,
+        visibility: string = FILE_VISIBILITY.NOBODY,
+        config: string | null = null
+    ) {
+
+        content = content.replace(/^data:image\/[\w\/]+;base64,/, "");
+
+        const data = JSON.stringify({
+            filename: filename,
+            content: content,
+            type: FILE_TYPE.UPLOADED,
+            visibility: visibility,
+            config: config
+        })
+
+        return this.apiRequestService.post("api/upload", data)
+    }
+
     /**
      * Convert the API response into base64
      * @param data