Explorar o código

post MR fixes

Olivier Massot %!s(int64=2) %!d(string=hai) anos
pai
achega
ab994e15a5

+ 4 - 0
components/Layout/Dialog.vue

@@ -83,6 +83,10 @@ const _show = computed(() => props.show) as boolean
     }
     }
   }
   }
 
 
+  .dialog-container {
+    overflow-x: scroll;
+  }
+
   .dialog-text-container {
   .dialog-text-container {
     max-height: 70vh;
     max-height: 70vh;
     overflow: auto;
     overflow: auto;

+ 2 - 2
components/Layout/Header/Menu.vue

@@ -17,7 +17,7 @@ header principal (configuration, paramètres du compte...)
           size="30"
           size="30"
       >
       >
         <UiImage
         <UiImage
-            :id="menu.icon.avatarId"
+            :imageId="menu.icon.avatarId"
             :defaultImage="menu.icon.avatarByDefault"
             :defaultImage="menu.icon.avatarByDefault"
             :width="30"
             :width="30"
         />
         />
@@ -56,7 +56,7 @@ header principal (configuration, paramètres du compte...)
               >
               >
                 <span v-if="child.icon" class="pr-2 d-flex align-center">
                 <span v-if="child.icon" class="pr-2 d-flex align-center">
                   <v-avatar v-if="menu.icon.avatarId || child.icon.avatarByDefault" size="30" >
                   <v-avatar v-if="menu.icon.avatarId || child.icon.avatarByDefault" size="30" >
-                    <UiImage :id="child.icon.avatarId" :defaultImage="child.icon.avatarByDefault" :width="30" />
+                    <UiImage :imageId="child.icon.avatarId" :defaultImage="child.icon.avatarByDefault" :width="30" />
                   </v-avatar>
                   </v-avatar>
                   <v-icon v-else class="on-primary" size="small">
                   <v-icon v-else class="on-primary" size="small">
                     {{ child.icon.name }}
                     {{ child.icon.name }}

+ 0 - 0
components/Layout/Parameters/ResidenceAreas.vue


+ 56 - 74
components/Ui/Image.vue

@@ -1,8 +1,6 @@
 <!--
 <!--
-Composant Image permettant de charger et d'afficher une image stockée sur les serveurs Opentalent à partir de son id.
+Composant Image permettant d'afficher une image stockée sur les serveurs Opentalent à partir de son id.
 Permet d'afficher une image par défaut si l'image demandée n'est pas disponible ou invalide.
 Permet d'afficher une image par défaut si l'image demandée n'est pas disponible ou invalide.
-
-Si la propriété 'upload' est à 'true', propose aussi un input pour uploader une nouvelle image.
 -->
 -->
 <template>
 <template>
   <main>
   <main>
@@ -27,94 +25,81 @@ Si la propriété 'upload' est à 'true', propose aussi un input pour uploader u
             />
             />
           </v-row>
           </v-row>
         </template>
         </template>
-      </v-img>
 
 
-      <div>
-        <div v-if="upload" class="click-action hover" @click="openUpload=true"><v-icon>mdi-upload</v-icon></div>
-        <UiInputImage
-          v-if="openUpload"
-          @close="openUpload=false"
-          :existingImageId="id"
-          :field="field"
-          :ownerId="ownerId"
-          @update="$emit('update', $event, field); openUpload=false"
-          @reload="onReload"
-          @reset="reset"
-        ></UiInputImage>
-      </div>
+        <div v-if="!pending && overlayIcon" class="overlay" @click="emit('overlay-clicked')">
+          <v-icon>{{ overlayIcon }}</v-icon>
+        </div>
+      </v-img>
     </div>
     </div>
   </main>
   </main>
 </template>
 </template>
 
 
-
 <script setup lang="ts">
 <script setup lang="ts">
-import {ref} from "@vue/reactivity";
-import type {Ref} from "@vue/reactivity";
 import {useImageFetch} from "~/composables/data/useImageFetch";
 import {useImageFetch} from "~/composables/data/useImageFetch";
-import {onUnmounted, watch} from "@vue/runtime-core";
-import type {WatchStopHandle} from "@vue/runtime-core";
-import {useImageManager} from "~/composables/data/useImageManager";
 import ImageManager from "~/services/data/imageManager";
 import ImageManager from "~/services/data/imageManager";
 
 
 const props = defineProps({
 const props = defineProps({
-  id: {
-    type: Number,
+  /**
+   * Id de l'image (null si aucune)
+   */
+  imageId: {
+    type: Number as PropType<number | null>,
     required: false,
     required: false,
     default: null
     default: null
   },
   },
+  /**
+   * Image par défaut
+   */
   defaultImage: {
   defaultImage: {
     type: String,
     type: String,
     required: false
     required: false
   },
   },
+  /**
+   * Hauteur de l'image à l'écran (en px)
+   */
   height: {
   height: {
     type: Number,
     type: Number,
     required: false
     required: false
   },
   },
+  /**
+   * Largeur de l'image à l'écran (en px)
+   */
   width: {
   width: {
     type: Number,
     type: Number,
     required: false
     required: false
   },
   },
-  field: {
+  /**
+   * Icône à afficher en overlay au survol de la souris
+   */
+  overlayIcon: {
     type: String,
     type: String,
-    required: false
-  },
-  upload: {
-    type: Boolean,
     required: false,
     required: false,
-    default: false
-  },
-  ownerId:{
-    type: Number,
-    required: false
+    default: null
   }
   }
 })
 })
 
 
-const { imageManager } = useImageManager()
 const { fetch } = useImageFetch()
 const { fetch } = useImageFetch()
 
 
 const defaultImagePath = props.defaultImage ?? ImageManager.defaultImage
 const defaultImagePath = props.defaultImage ?? ImageManager.defaultImage
 
 
-const openUpload: Ref<Boolean> = ref(false)
+const { data: imageSrc, pending, refresh: refreshImage } = fetch(props.imageId ?? null, defaultImagePath, props.height, props.width) as any
 
 
-const { data: imageSrc, pending, refresh } = fetch(props.id ?? null, defaultImagePath, props.height, props.width)
 
 
-const unwatch: WatchStopHandle = watch(() => props.id, async (newValue, oldValue) => {
-  await refresh()
-})
-
-const onReload = async () => {
-  await refresh()
-  openUpload.value = false
-}
+const emit = defineEmits(['overlay-clicked'])
 
 
 /**
 /**
- * Quand on souhaite faire un reset de l'image
+ * Si l'id change, on recharge l'image
  */
  */
-const reset = () => {
-  imageSrc.value = defaultImagePath
-  openUpload.value = false
+const unwatch: WatchStopHandle = watch(() => props.imageId, async () => {
+  await refreshImage()
+})
+
+const refresh = () => {
+  refreshImage()
 }
 }
 
 
+defineExpose({ refresh })
+
 /**
 /**
  * Lorsqu'on démonte le component, on supprime le watcher
  * Lorsqu'on démonte le component, on supprime le watcher
  */
  */
@@ -129,36 +114,33 @@ onUnmounted(() => {
     position: relative;
     position: relative;
 
 
     img {
     img {
+      display: block;
       max-width: 100%;
       max-width: 100%;
     }
     }
 
 
-    .click-action {
+    .overlay {
       position: absolute;
       position: absolute;
-      top:0;
-      left:0;
-      width: 100%;
+      top: 0;
+      bottom: 0;
+      left: 0;
+      right: 0;
       height: 100%;
       height: 100%;
-      background: transparent;
+      width: 100%;
       opacity: 0;
       opacity: 0;
-      transition: all .2s;
-      &:hover {
-        opacity: 1;
-        background: rgb(var(--v-theme-neutral-strong));
-        cursor: pointer;
-      }
-      i {
-        color: rgb(var(--v-theme-on-neutral-strong));
-        position: absolute;
-        top: 50%;
-        left: 50%;
-        transform: translate(-50% , -50%);
-        font-size: 50px;
-        z-index: 1;
-        opacity: 1;
-        &:hover{
-          color: rgb(var(--v-theme-primary-alt));
-        }
-      }
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      transition: .3s ease;
+    }
+    .overlay:hover {
+      opacity: 0.8;
+      background-color: rgb(var(--v-theme-neutral-strong));
+      cursor: pointer;
+    }
+
+    .overlay .v-icon {
+      color: rgb(var(--v-theme-on-neutral-strong));
+      font-size: 36px;
     }
     }
   }
   }
 </style>
 </style>

+ 1 - 1
components/Ui/Input/Autocomplete.vue

@@ -53,7 +53,7 @@ const props = defineProps({
    * v-model
    * v-model
    */
    */
   modelValue: {
   modelValue: {
-    type: [String, Number, Object, Array],
+    type: [String, Number, Object, Array] as PropType<any>,
     required: false,
     required: false,
     default: null
     default: null
   },
   },

+ 292 - 119
components/Ui/Input/Image.vue

@@ -1,15 +1,28 @@
 <!--
 <!--
 Assistant de création d'image
 Assistant de création d'image
-https://norserium.github.io/vue-advanced-cropper/
+
+@see https://norserium.github.io/vue-advanced-cropper/
 -->
 -->
 <template>
 <template>
-    <LazyLayoutDialog :show="true">
+  <div class="input-image" >
+    <UiImage
+        ref="imageElement"
+        :image-id="modelValue"
+        :default-image="defaultImage"
+        :width="width"
+        :height="height"
+        class="image"
+        overlay-icon="fas fa-upload"
+        @overlay-clicked="openModal()"
+    />
+
+    <LazyLayoutDialog :show="showModal">
       <template #dialogType>{{ $t('image_assistant') }}</template>
       <template #dialogType>{{ $t('image_assistant') }}</template>
       <template #dialogTitle>{{ $t('modif_picture') }}</template>
       <template #dialogTitle>{{ $t('modif_picture') }}</template>
       <template #dialogText>
       <template #dialogText>
         <div class="upload">
         <div class="upload">
           <v-row
           <v-row
-            v-if="fetchState.pending"
+            v-if="pending"
             class="fill-height ma-0 loading"
             class="fill-height ma-0 loading"
             align="center"
             align="center"
             justify="center"
             justify="center"
@@ -22,231 +35,382 @@ https://norserium.github.io/vue-advanced-cropper/
 
 
           <div v-else >
           <div v-else >
             <div class="upload__cropper-wrapper">
             <div class="upload__cropper-wrapper">
-              <cropper
+              <Cropper
                 ref="cropper"
                 ref="cropper"
                 class="upload__cropper"
                 class="upload__cropper"
                 check-orientation
                 check-orientation
-                :src="image.src"
-                :default-position="{left : coordinates.left, top : coordinates.top}"
-                :default-size="coordinates.width ? {width : coordinates.width, height : coordinates.height}: defaultSize"
-                @change="onChange"
+                :src="uploadedImage.src"
+                :default-position="defaultPosition"
+                :default-size="defaultSize"
+                @change="onCropperChange"
               />
               />
-              <div v-if="image.src" class="upload__reset-button" title="Reset Image" @click="reset()">
-                <v-icon>mdi-delete</v-icon>
+
+              <div
+                  v-if="uploadedImage.src"
+                  class="upload__reset-button"
+                  title="Reset Image"
+                  @click="reset()"
+              >
+                <v-icon>fas fa-trash</v-icon>
               </div>
               </div>
             </div>
             </div>
             <div class="upload__buttons-wrapper">
             <div class="upload__buttons-wrapper">
-              <button class="upload__button" @click="$refs.file.click()">
-                <input ref="file" type="file" accept="image/*" @change="uploadImage($event)" />
+              <button class="upload__button" @click="fileInput?.click()">
+                <input ref="fileInput" type="file" accept="image/*" @change="uploadImage($event)" />
                 {{$t('upload_image')}}
                 {{$t('upload_image')}}
               </button>
               </button>
             </div>
             </div>
+            <span class="max-size-label">{{ $t('max_size_4_mb') }}</span>
           </div>
           </div>
 
 
         </div>
         </div>
       </template>
       </template>
       <template #dialogBtn>
       <template #dialogBtn>
-        <v-btn class="mr-4 submitBtn theme-neutral-strong" @click="$emit('close')">
+        <v-btn class="mr-4 submitBtn theme-neutral-strong" @click="cancel">
           {{ $t('cancel') }}
           {{ $t('cancel') }}
         </v-btn>
         </v-btn>
-        <v-btn class="mr-4 submitBtn theme-danger" @click="save">
+        <v-btn class="submitBtn theme-primary" @click="save" :disabled="pending">
           {{ $t('save') }}
           {{ $t('save') }}
         </v-btn>
         </v-btn>
       </template>
       </template>
     </LazyLayoutDialog>
     </LazyLayoutDialog>
-
+  </div>
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
 
 
-import {useNuxtApp} from "#app";
-import {ref} from "@vue/reactivity";
-import type {Ref} from "@vue/reactivity";
+import {ref, Ref} from "@vue/reactivity";
 import File from '~/models/Core/File'
 import File from '~/models/Core/File'
-import {useAp2iRequestService} from "~/composables/data/useAp2iRequestService";
-import UrlUtils from "~/services/utils/urlUtils";
-import {useImageFetch} from "~/composables/data/useImageFetch";
-import {onUnmounted, watch} from "@vue/runtime-core";
-import type {WatchStopHandle} from "@vue/runtime-core";
+import {PropType, watch} from "@vue/runtime-core";
 import {useEntityManager} from "~/composables/data/useEntityManager";
 import {useEntityManager} from "~/composables/data/useEntityManager";
-import type {AnyJson} from "~/types/data";
+import {AnyJson} from "~/types/data";
+import ApiResource from "~/models/ApiResource";
+import {useImageManager} from "~/composables/data/useImageManager";
+import { 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 {usePageStore} from "~/stores/page";
 
 
 const props = defineProps({
 const props = defineProps({
-  imageId: {
+  /**
+   * Id de l'objet File, ou null
+   */
+  modelValue: {
+    type: Number as PropType<number | null>,
+    required: false,
+    default: null
+  },
+  /**
+   * Label du champ
+   * Si non défini, c'est le nom de propriété qui est utilisé
+   */
+  field: {
+    type: String,
+    required: false,
+    default: null
+  },
+  /**
+   * Image par défaut en cas d'absence d'une image uploadée
+   */
+  defaultImage: {
+    type: String,
+    required: false
+  },
+  /**
+   * Hauteur de l'image à l'écran (en px)
+   */
+  height: {
     type: Number,
     type: Number,
     required: false
     required: false
   },
   },
-  ownerId: {
+  /**
+   * Largeur de l'image à l'écran (en px)
+   */
+  width: {
     type: Number,
     type: Number,
     required: false
     required: false
   },
   },
-  field: {
-    type: String,
-    required: true
+  /**
+   * TODO: completer
+   */
+  ownerId: {
+    type: Number,
+    required: false
   }
   }
 })
 })
 
 
-const { emit } = useNuxtApp()
-
 const { em } = useEntityManager()
 const { em } = useEntityManager()
+const { imageManager } = useImageManager()
+const pageStore = usePageStore()
 
 
-const file = new File()
+const emit = defineEmits(['update:modelValue', 'update:image', 'reset'])
 
 
+/**
+ * Références à des composants
+ */
+const fileInput: Ref<null | any> = ref(null)
 const cropper: Ref<any> = ref(null)
 const cropper: Ref<any> = ref(null)
+const imageElement: Ref<any> = ref(null)
 
 
-const image: Ref<AnyJson> = ref({
+/**
+ * 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)
+
+/**
+ * L'objet File ou l'image sont en cours de chargement
+ */
+const pending: Ref<boolean> = ref(false)
+
+/**
+ * Affiche la modale d'upload / modification de l'image
+ */
+const showModal = ref(false)
+
+/**
+ * Données d'une nouvelle image uploadée par l'utilisateur
+ */
+const uploadedImage: Ref<AnyJson> = ref({
   id: null,
   id: null,
   src: null,
   src: null,
   file: null,
   file: null,
   name: null
   name: null
 })
 })
 
 
-const coordinates: Ref<AnyJson> = ref({})
+const MAX_FILE_SIZE = 4 * 1024 * 1024
 
 
-const defaultSize = ({ imageSize, visibleArea }: any) => {
-  return {
-    width: (visibleArea || imageSize).width,
-    height: (visibleArea || imageSize).height,
-  };
-}
+/**
+ * Coordonnées du cropper
+ */
+const coordinates: Ref<{ left?: number, top?: number, height?: number, width?: number }> = ref({})
 
 
-// Si l'id est renseigné, on récupère l'Item File afin d'avoir les informations de config, le nom, etc.
-if (props.imageId && props.imageId > 0) {
-  const { apiRequestService } = useAp2iRequestService()
-  const result: any = await apiRequestService.get(UrlUtils.join('api/files', '' + props.imageId))
-
-  const config = JSON.parse(result.data.config)
-  coordinates.value.left = config.x
-  coordinates.value.top = config.y
-  coordinates.value.height = config.height
-  coordinates.value.width = config.width
-  image.value.name = result.data.name
-  image.value.id = result.data.id
+/**
+ * @see https://advanced-cropper.github.io/vue-advanced-cropper/components/cropper.html#defaultposition
+ */
+const defaultPosition = () => {
+  return { left : coordinates.value.left, top : coordinates.value.top }
 }
 }
 
 
-//On récupère l'image...
-const { fetch } = useImageFetch()
-const { data: imageLoaded, pending } = fetch(props.imageId ?? null)
+/**
+ * @see https://advanced-cropper.github.io/vue-advanced-cropper/components/cropper.html#defaultsize
+ */
+const defaultSize = (params: any): { width: number, height: number } | null => {
+  if (!params) {
+    return null
+  }
+  const { imageSize, visibleArea } = params
+
+  return {
+    width: coordinates.value.width ?? (visibleArea || imageSize).width,
+    height: coordinates.value.height ?? (visibleArea || imageSize).height
+  }
+}
 
 
-const unwatch: WatchStopHandle = watch(
-  imageLoaded,
-(newValue, oldValue) => {
-    if (newValue === oldValue || typeof newValue === 'undefined') {
-      return
+/**
+ * Affiche la modale d'upload / modification de l'image
+ */
+const openModal = async () => {
+  showModal.value = true
+  pending.value = true
+
+  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
     }
     }
-    image.value.src = newValue
+
+    uploadedImage.value.name = file.value.name
+    uploadedImage.value.id = file.value.id
+    uploadedImage.value.src = image.value
+
+  } else {
+    // Nouveau File
+    file.value = em.newInstance(File)
   }
   }
-)
+
+  pending.value = false
+}
+
+/**
+ * Réinitialise l'image sélectionnée
+ */
+const reset = () => {
+  uploadedImage.value.src = null
+  uploadedImage.value.file = null
+  uploadedImage.value.name = null
+  uploadedImage.value.id = null
+  URL.revokeObjectURL(uploadedImage.value.src)
+}
 
 
 /**
 /**
- * Quand l'utilisateur choisit une image sur sa machine
+ * Upload une image depuis le poste client
  * @param event
  * @param event
  */
  */
-const uploadImage = (event:any) => {
+const uploadImage = (event: any) => {
   const { files } = event.target
   const { files } = event.target
-  if (files && files[0]) {
-    reset()
-    image.value.name = files[0].name
-    image.value.src = URL.createObjectURL(files[0])
-    image.value.file = files[0]
+
+  if (!files || !files[0]) {
+    return
+  }
+
+  const uploadedFile = files[0]
+
+  if (uploadedFile.size > MAX_FILE_SIZE) {
+    pageStore.alerts.push({type: TYPE_ALERT.ALERT, messages: ['file_too_large'] })
+    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
 }
 }
 
 
 /**
 /**
  * Lorsque le cropper change de position / taille, on met à jour les coordonnées
  * Lorsque le cropper change de position / taille, on met à jour les coordonnées
- * @param config
+ * @param newCoordinates
  */
  */
-const onChange = ({ coordinates: config } : any) => {
-  coordinates.value = config;
+const onCropperChange = ({ coordinates: newCoordinates } : any) => {
+  coordinates.value = newCoordinates;
 }
 }
 
 
 /**
 /**
- * Lorsque l'on sauvegarde l'image
+ * Annule l'upload et les modifications, et ferme la modale
  */
  */
-// TODO: Voir si tout ou partie de cette fonction peut passer dans le useImageFetch, imageManager ou imageUtils
-const save = async () => {
-  file.config = JSON.stringify({
+const cancel = () => {
+  reset()
+  showModal.value = false
+}
+
+/**
+ * Construit la nouvelle config du fichier à partir des réglages actuels
+ */
+const updateFileConfig = () => {
+  file.value!!.config = JSON.stringify({
     x: coordinates.value.left,
     x: coordinates.value.left,
     y: coordinates.value.top,
     y: coordinates.value.top,
     height: coordinates.value.height,
     height: coordinates.value.height,
     width: coordinates.value.width
     width: coordinates.value.width
   })
   })
+}
 
 
-  if (image.value.id > 0) {
-    // Mise à jour d'une image existante : on bouge simplement le cropper
-
-    file.id = image.value.id as number
+/**
+ * Enregistre une nouvelle image
+ */
+const saveNewImage = async () => {
+  if (!file.value) {
+    throw new Error('No File object defined')
+  }
 
 
-    await em.persist(File, file) // TODO: à revoir
+  // 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
 
 
-    // On émet un évent afin de mettre à jour le formulaire de départ
-    emit('reload')
+  updateFileConfig()
 
 
-  } else {
-    // Création d'une nouvelle image
-    if (image.value.file) {
-
-      // On créé l'objet File à sauvegarder
-      file.name = image.value.name
-      file.imgFieldName = props.field
-      file.visibility = 'EVERYBODY'
-      file.folder = 'IMAGES'
-      file.status = 'READY'
-
-      if (props.ownerId) {
-        file.ownerId = props.ownerId
-      }
+  if (props.ownerId) {
+    // TODO: revoir
+    file.value.ownerId = props.ownerId
+  }
 
 
-      const returnedFile = await em.persist(File, file) // TODO: à revoir, il faudra pouvoir passer `image.value.file` avec la requête
+  // 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)
 
 
-      //On émet un évent afin de mettre à jour le formulaire de départ
-      emit('update', returnedFile.data['@id'])
+  //On émet un évent afin de mettre à jour le formulaire de départ
+  emit('update:modelValue', returnedFile.id)
+}
 
 
-    } else {
-      // On reset l'image : on a appuyé sur "poubelle" puis on enregistre
-      emit('reset')
-    }
+/**
+ * Met à jour l'image existante
+ */
+const saveExistingImage = async () => {
+  if (!file.value) {
+    throw new Error('No File object defined')
   }
   }
+
+  updateFileConfig()
+
+  await em.persist(File, file.value) // TODO: à revoir
 }
 }
 
 
 /**
 /**
- * On choisit de supprimer l'image présente
+ * Lorsque l'on sauvegarde l'image
  */
  */
-const reset = () => {
-  image.value.src = null
-  image.value.file = null
-  image.value.name = null
-  image.value.id = null
-  URL.revokeObjectURL(image.value.src)
+// 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) {
+    // Une nouvelle image a été uploadée
+    await saveNewImage()
+  } else if (uploadedImage.value.id) {
+    // L'image existante a été modifiée
+    await saveExistingImage()
+  } else {
+    // On a reset l'image
+    emit('update:modelValue', null)
+  }
+
+  imageElement.value.refresh()
+
+  showModal.value = false
+  pageStore.loading = false
 }
 }
 
 
 /**
 /**
  * Lorsqu'on démonte le component on supprime le watcher et on revoke l'objet URL
  * Lorsqu'on démonte le component on supprime le watcher et on revoke l'objet URL
  */
  */
 onUnmounted(() => {
 onUnmounted(() => {
-  unwatch()
-    if (image.value.src) {
-      URL.revokeObjectURL(image.value.src)
-    }
+  if (uploadedImage.value && uploadedImage.value.src) {
+    URL.revokeObjectURL(uploadedImage.value.src)
+  }
 })
 })
 </script>
 </script>
 
 
-<style lang="scss">
-  .vue-advanced-cropper__stretcher{
+<style scoped lang="scss">
+  :deep(.vue-advanced-cropper__stretcher) {
     height: auto !important;
     height: auto !important;
     width: auto !important;
     width: auto !important;
   }
   }
+
   .loading{
   .loading{
     height: 300px;
     height: 300px;
   }
   }
+
   .upload {
   .upload {
     user-select: none;
     user-select: none;
     padding: 20px;
     padding: 20px;
     display: block;
     display: block;
     &__cropper {
     &__cropper {
        border: solid 1px rgb(var(--v-theme-on-neutral-strong));;
        border: solid 1px rgb(var(--v-theme-on-neutral-strong));;
-       min-height: 500px;
-       max-height: 500px;
+       min-height: 300px;
+       max-height: 300px;
      }
      }
     &__cropper-wrapper {
     &__cropper-wrapper {
        position: relative;
        position: relative;
@@ -291,4 +455,13 @@ onUnmounted(() => {
       }
       }
     }
     }
   }
   }
+
+  .max-size-label {
+    display: block;
+    width: 100%;
+    text-align: center;
+    font-size: 13px;
+    color: rgb(var(--v-theme-on-neutral-soft));
+    margin-top: 6px;
+  }
 </style>
 </style>

+ 2 - 2
composables/data/useImageFetch.ts

@@ -2,7 +2,7 @@ import {useImageManager} from "~/composables/data/useImageManager";
 import type {FetchResult} from "#app";
 import type {FetchResult} from "#app";
 
 
 interface useImageFetchReturnType {
 interface useImageFetchReturnType {
-    fetch: (id: number | null, defaultImage?: string | null, height?: number, width?: number) => FetchResult<any, any>
+    fetch: (id: number | null, defaultImage?: string | null, height?: number, width?: number) => AsyncData<string | ArrayBuffer | null, Error | null>
 }
 }
 
 
 /**
 /**
@@ -16,7 +16,7 @@ export const useImageFetch = (): useImageFetchReturnType => {
         defaultImage: string | null = null,
         defaultImage: string | null = null,
         height: number = 0,
         height: number = 0,
         width: number = 0
         width: number = 0
-    ): FetchResult<string, any> => useAsyncData(
+    ) => useAsyncData(
         'img' + (id ?? defaultImage ?? 0),
         'img' + (id ?? defaultImage ?? 0),
         () => imageManager.get(id, defaultImage, height, width),
         () => imageManager.get(id, defaultImage, height, width),
         { lazy: true, server: false }  // Always fetch images client-side
         { lazy: true, server: false }  // Always fetch images client-side

+ 6 - 3
lang/fr.json

@@ -210,7 +210,8 @@
   "newSubDomain": "Nouveau sous domaine",
   "newSubDomain": "Nouveau sous domaine",
   "yourSubdomains": "Vos sous-domaines",
   "yourSubdomains": "Vos sous-domaines",
   "timezone": "Fuseau horaire",
   "timezone": "Fuseau horaire",
-  "qrCode": "QrCode pour la licence",
+  "qrCode": "QrCode",
+  "qrCodeForLicence": "QrCode pour la licence",
   "studentsAreAdherents": "Les élèves sont également adhérents de l'association",
   "studentsAreAdherents": "Les élèves sont également adhérents de l'association",
   "showAdherentList": "Afficher la liste des adhérents et leurs coordonnées",
   "showAdherentList": "Afficher la liste des adhérents et leurs coordonnées",
   "endCourseDate": "Date de fin des cours ",
   "endCourseDate": "Date de fin des cours ",
@@ -300,7 +301,7 @@
   "phoneNumberInvalid": "Numéro de téléphone invalide",
   "phoneNumberInvalid": "Numéro de téléphone invalide",
   "logo": "Logo",
   "logo": "Logo",
   "subdomain": "Sous-domaine",
   "subdomain": "Sous-domaine",
-  "upload_image": "Sélectionner une image",
+  "upload_image": "Charger une image",
   "of": "de",
   "of": "de",
   "allResult": "Tous",
   "allResult": "Tous",
   "itemsPerPage": "Nombre de résultats par page",
   "itemsPerPage": "Nombre de résultats par page",
@@ -667,5 +668,7 @@
   "residence_areas_breadcrumbs": "Zones de résidence",
   "residence_areas_breadcrumbs": "Zones de résidence",
   "super_admin_explanation_text": "Le compte super-admin possède tous les droits de gestion sur votre logiciel. On l’utilise entre autre pour la gestion de votre site internet, pour créer les comptes des membres de votre structure à la première connexion au logiciel, ou dans des situations de dépannage.",
   "super_admin_explanation_text": "Le compte super-admin possède tous les droits de gestion sur votre logiciel. On l’utilise entre autre pour la gestion de votre site internet, pour créer les comptes des membres de votre structure à la première connexion au logiciel, ou dans des situations de dépannage.",
   "cycles_breadcrumbs": "Enseignements",
   "cycles_breadcrumbs": "Enseignements",
-  "exit": "Quitter"
+  "exit": "Quitter",
+  "max_size_4_mb": "Taille maximum: 4 MO",
+  "file_too_large": "Le fichier est trop volumineux"
 }
 }

+ 3 - 1
models/Organization/Parameters.ts

@@ -3,6 +3,7 @@ import { Bool, Num, Str, Uid, Attr } from 'pinia-orm/dist/decorators'
 import Access from "~/models/Access/Access";
 import Access from "~/models/Access/Access";
 import ApiResource from "~/models/ApiResource";
 import ApiResource from "~/models/ApiResource";
 import {IriEncoded} from "~/models/decorators";
 import {IriEncoded} from "~/models/decorators";
+import File from "~/models/Core/File";
 
 
 /**
 /**
  * AP2i Model : Parameters
  * AP2i Model : Parameters
@@ -104,7 +105,8 @@ export default class Parameters extends ApiModel {
   declare studentsAreAdherents: boolean
   declare studentsAreAdherents: boolean
 
 
   @Str(null)
   @Str(null)
-  declare qrCode: string | null
+  @IriEncoded(File)
+  declare qrCode: number | null
 
 
   @Str('Europe/Paris')
   @Str('Europe/Paris')
   declare timezone: string | null
   declare timezone: string | null

+ 1 - 0
package.json

@@ -39,6 +39,7 @@
     "sass": "^1.69.5",
     "sass": "^1.69.5",
     "uuid": "^9.0.1",
     "uuid": "^9.0.1",
     "vite-plugin-vuetify": "^1.0.2",
     "vite-plugin-vuetify": "^1.0.2",
+    "vue-advanced-cropper": "^2.8.8",
     "vue-tel-input-vuetify": "^1.5.3",
     "vue-tel-input-vuetify": "^1.5.3",
     "vue-the-mask": "^0.11.1",
     "vue-the-mask": "^0.11.1",
     "vuetify": "3.4.6",
     "vuetify": "3.4.6",

+ 1 - 2
pages/organization/index.vue

@@ -43,8 +43,7 @@ Contient toutes les informations sur l'organization courante
                     </UiHelp>
                     </UiHelp>
                   </div>
                   </div>
                   <UiImage
                   <UiImage
-                    :id="getIdFromUri(organization.logo)"
-                    :upload="true"
+                    :imageId="getIdFromUri(organization.logo)"
                     :width="200"
                     :width="200"
                     field="logo"
                     field="logo"
                     :ownerId="id"
                     :ownerId="id"

+ 5 - 0
plugins/vue-advanced-cropper.ts

@@ -0,0 +1,5 @@
+import { Cropper } from 'vue-advanced-cropper'
+import 'vue-advanced-cropper/dist/style.css';
+
+export default defineNuxtPlugin((nuxtApp) => {
+})

+ 3 - 2
services/data/normalizer/hydraNormalizer.ts

@@ -150,11 +150,12 @@ class HydraNormalizer {
 
 
     for (const field in iriEncodedFields) {
     for (const field in iriEncodedFields) {
       const value = instance[field]
       const value = instance[field]
-      if (value === null) {
-        continue
+      if (_.isEmpty(value)) {
+          continue
       }
       }
 
 
       const targetEntity = iriEncodedFields[field].entity
       const targetEntity = iriEncodedFields[field].entity
+
       if (_.isArray(value)) {
       if (_.isArray(value)) {
         instance[field] = value.map((iri: string) => {
         instance[field] = value.map((iri: string) => {
           return HydraNormalizer.getIdFromEntityIri(iri, targetEntity)
           return HydraNormalizer.getIdFromEntityIri(iri, targetEntity)

+ 29 - 0
tests/units/services/data/normalizer/hydraNormalizer.test.ts

@@ -439,6 +439,35 @@ describe('denormalizeEntity', () => {
             oneToManyRelation: [123, 124, 125]
             oneToManyRelation: [123, 124, 125]
         }))
         }))
     })
     })
+
+    test('should handle relations with empty values', () => {
+        const data = {
+            id: 7351,
+            name: null,
+            oneToOneRelation: null,
+            oneToManyRelation: []
+        }
+
+        //@ts-ignore
+        HydraNormalizer.getIriEncodedFields = vi.fn(
+            (entity) => {
+                return {
+                    oneToOneRelation: DummyApiChild,
+                    oneToManyRelation: DummyApiChild
+                }
+            }
+        )
+
+        //@ts-ignore
+        const result = HydraNormalizer.denormalizeEntity(DummyApiModel, data)
+
+        expect(result).toStrictEqual(new DummyApiModel({
+            id: 7351,
+            name: null,
+            oneToOneRelation: null,
+            oneToManyRelation: []
+        }))
+    })
 })
 })
 
 
 describe('denormalizeEnum', () => {
 describe('denormalizeEnum', () => {

+ 24 - 0
types/enum/enums.ts

@@ -75,3 +75,27 @@ export const enum SUBMIT_TYPE {
   SAVE_AND_BACK = 'save_and_back'
   SAVE_AND_BACK = 'save_and_back'
 }
 }
 
 
+export const enum FILE_VISIBILITY {
+  EVERYBODY = 'EVERYBODY',
+  NOBODY = 'NOBODY',
+  ONLY_ORGANIZATION = 'ONLY_ORGANIZATION'
+}
+
+export const enum FILE_FOLDER {
+  IMAGES = 'IMAGES'
+}
+
+export const enum FILE_STATUS {
+  PENDING = 'PENDING',
+  READY = 'READY',
+  DELETED = 'DELETED',
+  ERROR = 'ERROR',
+}
+
+export const enum FILE_TYPE {
+  UNKNOWN = 'UNKNOWN',
+  NONE = 'NONE',
+  LICENCE_CMF ='LICENCE_CMF',
+  BILL ='BILL',
+  UPLOADED = 'UPLOADED'
+}

+ 8 - 8
yarn.lock

@@ -3670,10 +3670,10 @@ eastasianwidth@^0.2.0:
   resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb"
   resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb"
   integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==
   integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==
 
 
-editorconfig@^1.0.3:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/editorconfig/-/editorconfig-1.0.4.tgz#040c9a8e9a6c5288388b87c2db07028aa89f53a3"
-  integrity sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==
+editorconfig@^0.15.3:
+  version "0.15.3"
+  resolved "https://registry.yarnpkg.com/editorconfig/-/editorconfig-0.15.3.tgz#bef84c4e75fb8dcb0ce5cee8efd51c15999befc5"
+  integrity sha512-M9wIMFx96vq0R4F+gRpY3o2exzb8hEj/n9S8unZtHSvYjibBp/iMufSzvmOcV/laG0ZtuTVGtiJggPOSW2r93g==
   dependencies:
   dependencies:
     "@one-ini/wasm" "0.1.1"
     "@one-ini/wasm" "0.1.1"
     commander "^10.0.0"
     commander "^10.0.0"
@@ -9007,10 +9007,10 @@ vscode-uri@^3.0.2:
   resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-3.0.8.tgz#1770938d3e72588659a172d0fd4642780083ff9f"
   resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-3.0.8.tgz#1770938d3e72588659a172d0fd4642780083ff9f"
   integrity sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==
   integrity sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==
 
 
-vue-bundle-renderer@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/vue-bundle-renderer/-/vue-bundle-renderer-2.0.0.tgz#ecab5c9b2803ab2454ba212afef502e684ddbb8e"
-  integrity sha512-oYATTQyh8XVkUWe2kaKxhxKVuuzK2Qcehe+yr3bGiaQAhK3ry2kYE4FWOfL+KO3hVFwCdLmzDQTzYhTi9C+R2A==
+vue-bundle-renderer@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/vue-bundle-renderer/-/vue-bundle-renderer-1.0.3.tgz#422438aa4a024e2833e87a5a2d0b97c6c12fb2d8"
+  integrity sha512-EfjX+5TTUl70bki9hPuVp+54JiZOvFIfoWBcfXsSwLzKEiDYyHNi5iX8srnqLIv3YRnvxgbntdcG1WPq0MvffQ==
   dependencies:
   dependencies:
     ufo "^1.2.0"
     ufo "^1.2.0"