Vincent GUFFON 3 years ago
parent
commit
b5290a0a18

+ 120 - 47
components/Ui/Image.vue

@@ -1,32 +1,49 @@
 <template>
   <main>
-    <v-img
-      :src="imageLoaded"
-      :lazy-src="require(`/assets/images/byDefault/${imageByDefault}`)"
-      :min-height="height"
-      :min-width="width"
-      aspect-ratio="1"
-    >
-      <template v-slot:placeholder>
-        <v-row
-          class="fill-height ma-0"
-          align="center"
-          justify="center"
-        >
-          <v-progress-circular
-            indeterminate
-            color="grey lighten-1"
-          ></v-progress-circular>
-        </v-row>
-      </template>
-    </v-img>
+    <div class="image-wrapper" :style="{width:width + 'px'}">
+      <v-img
+        :src="imgSrcReload ? imgSrcReload : imageLoaded"
+        :lazy-src="require(`/assets/images/byDefault/${imageByDefault}`)"
+        :height="height"
+        :width="width"
+        aspect-ratio="1"
+      >
+        <template v-slot:placeholder>
+          <v-row
+            class="fill-height ma-0"
+            align="center"
+            justify="center"
+          >
+            <v-progress-circular
+              indeterminate
+              color="grey lighten-1"
+            ></v-progress-circular>
+          </v-row>
+        </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="fetch();openUpload=false"
+          @reset="reset"
+        ></UiInputImage>
+      </div>
+    </div>
   </main>
 </template>
 
 
 <script lang="ts">
-import {defineComponent, ref, Ref, useContext, useFetch} from '@nuxtjs/composition-api'
-import {QUERY_TYPE} from "~/types/enums";
+import {defineComponent, onUnmounted, ref, Ref, watch} from '@nuxtjs/composition-api'
+import {UseImage} from "~/use/data/useImage";
+import {WatchStopHandle} from "@vue/composition-api";
 
 export default defineComponent({
   props: {
@@ -34,6 +51,10 @@ export default defineComponent({
       type: Number,
       required: false
     },
+    field: {
+      type: String,
+      required: false
+    },
     imageByDefault: {
       type: String,
       required: false,
@@ -41,43 +62,95 @@ export default defineComponent({
     },
     height: {
       type: Number,
-      required: false,
-      default: 0
+      required: false
     },
     width: {
       type: Number,
+      required: false
+    },
+    upload: {
+      type: Boolean,
       required: false,
-      default: 0
+      default: false
+    },
+    ownerId:{
+      type: Number,
+      required: false
     }
   },
   fetchOnServer: false,
   setup(props) {
-    const {$dataProvider, $config} = useContext()
-    const imageLoaded: Ref<String> = ref('')
+    const openUpload: Ref<Boolean> = ref(false)
+    const imgSrcReload: Ref<any> = ref(null)
 
-    useFetch(async () => {
-        try{
-          if(props.id){
-            imageLoaded.value = await $dataProvider.invoke({
-              type: QUERY_TYPE.IMAGE,
-              baseUrl: $config.baseURL_Legacy,
-              imgArgs: {
-                id: props.id,
-                height: props.height,
-                width: props.width
-              }
-            })
-          }else
-            throw new Error('id is null')
-        }catch (e){
-          imageLoaded.value = require(`/assets/images/byDefault/${props.imageByDefault}`)
-        }
-      }
-    )
+    const useImg = new UseImage()
+
+    const { imageLoaded, fetch } = useImg.getOne(props.id, props.imageByDefault, props.height, props.width)
+    const unwatch: WatchStopHandle = watch(() => props.id, async (newValue, oldValue) => {
+      imgSrcReload.value = await useImg.provideImg(newValue as number, props.height, props.width)
+    })
+
+    /**
+     * Quand on souhaite faire un reset de l'image
+     */
+    const reset = () => {
+      imgSrcReload.value = null
+      imageLoaded.value = require(`assets/images/byDefault/${props.imageByDefault}`)
+      openUpload.value = false
+    }
+
+    /**
+     * Lorsqu'on démonte le component on supprime le watcher
+     */
+    onUnmounted(() => {
+      unwatch()
+    })
 
     return {
-      imageLoaded
+      imgSrcReload,
+      imageLoaded,
+      openUpload,
+      fetch,
+      reset
     }
   }
 })
 </script>
+
+<style lang="scss">
+  div.image-wrapper {
+    display: block;
+    position: relative;
+    img{
+      max-width: 100%;
+    }
+    .click-action{
+      position: absolute;
+      top:0;
+      left:0;
+      width: 100%;
+      height: 100%;
+      background: transparent;
+      opacity: 0;
+      transition: all .2s;
+      &:hover{
+        opacity: 1;
+        background:rgba(0,0,0,0.3);
+        cursor: pointer;
+      }
+      i{
+        color: #fff;
+        position: absolute;
+        top: 50%;
+        left: 50%;
+        transform: translate(-50% , -50%);
+        font-size: 50px;
+        z-index: 1;
+        opacity: 1;
+        &:hover{
+          color: rgba(#3fb37f, 0.7);
+        }
+      }
+    }
+  }
+</style>

+ 305 - 0
components/Ui/Input/Image.vue

@@ -0,0 +1,305 @@
+<!--
+Assistant de création d'image
+https://norserium.github.io/vue-advanced-cropper/
+-->
+<template>
+    <lazy-LayoutDialog :show="true">
+      <template v-slot:dialogType>{{ $t('image_assistant') }}</template>
+      <template v-slot:dialogTitle>{{ $t('modif_picture') }}</template>
+      <template v-slot:dialogText>
+        <div class="upload">
+          <v-row
+            v-if="fetchState.pending"
+            class="fill-height ma-0 loading"
+            align="center"
+            justify="center"
+          >
+            <v-progress-circular
+              indeterminate
+              color="grey lighten-1"
+            ></v-progress-circular>
+          </v-row>
+
+          <div v-else >
+            <div class="upload__cropper-wrapper">
+              <cropper
+                ref="cropper"
+                class="upload__cropper"
+                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"
+              />
+              <div v-if="image.src" class="upload__reset-button" title="Reset Image" @click="reset()">
+                <v-icon>mdi-delete</v-icon>
+              </div>
+            </div>
+            <div class="upload__buttons-wrapper">
+              <button class="upload__button" @click="$refs.file.click()">
+                <input ref="file" type="file" accept="image/*" @change="loadImage($event)" />
+                {{$t('upload_image')}}
+              </button>
+            </div>
+          </div>
+
+        </div>
+      </template>
+      <template v-slot:dialogBtn>
+        <v-btn class="mr-4 submitBtn ot_grey ot_white--text" @click="$emit('close')">
+          {{ $t('cancel') }}
+        </v-btn>
+        <v-btn class="mr-4 submitBtn ot_danger ot_white--text" @click="save">
+          {{ $t('save') }}
+        </v-btn>
+      </template>
+    </lazy-LayoutDialog>
+
+</template>
+
+<script lang="ts">
+import {defineComponent, onUnmounted, Ref, ref, useContext, useFetch, watch} from '@nuxtjs/composition-api'
+import { Cropper } from 'vue-advanced-cropper'
+import 'vue-advanced-cropper/dist/style.css';
+import {AnyJson, ApiResponse} from "~/types/interfaces";
+import {UseImage} from "~/use/data/useImage";
+import {WatchStopHandle} from "@vue/composition-api";
+import {QUERY_TYPE} from "~/types/enums";
+import {File} from "~/models/Core/File";
+import {repositoryHelper} from "~/services/store/repository";
+
+export default defineComponent({
+  components: {
+    Cropper,
+  },
+  props: {
+    existingImageId:{
+      type: Number,
+      required: false
+    },
+    ownerId:{
+      type: Number,
+      required: false
+    },
+    field:{
+      type: String,
+      required: true
+    }
+  },
+  fetchOnServer: false,
+  setup (props, {emit}) {
+    const fileToSave = new File()
+    const cropper:Ref<any> = ref(null)
+    const image: Ref<AnyJson> = ref({
+      id: null,
+      src: null,
+      file: null,
+      name: null
+    })
+    const coordinates:Ref<AnyJson> = ref({})
+    const defaultSize = ({ imageSize, visibleArea }: any) => {
+      return {
+        width: (visibleArea || imageSize).width,
+        height: (visibleArea || imageSize).height,
+      };
+    }
+    const {$dataProvider, $config, $dataPersister} = useContext()
+
+    //Si l'id est renseigné, il faut récupérer l'Item File afin d'avoir les informations de config, le nom, etc.
+    if(props.existingImageId){
+      useFetch(async () => {
+        const result: ApiResponse = await $dataProvider.invoke({
+          type: QUERY_TYPE.DEFAULT,
+          url: 'api/files',
+          id: props.existingImageId
+        })
+
+        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
+      })
+    }
+
+    //On récupère l'image...
+    const { fetchState, imageLoaded } = new UseImage().getOne(props.existingImageId)
+    const unwatch: WatchStopHandle = watch(imageLoaded, (newValue, oldValue) => {
+      if (newValue === oldValue || typeof newValue === 'undefined') { return }
+      image.value.src = newValue
+    })
+
+    /**
+     * Quand l'utilisateur choisit une image sur sa machine
+     * @param event
+     */
+    const loadImage = (event:any) => {
+      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]
+      }
+    }
+
+    /**
+     * Losrque le cropper change de position/taille, on met à jour les coordonnées
+     * @param config
+     */
+    const onChange = ({ coordinates: config } : any) => {
+      coordinates.value = config;
+    }
+
+    /**
+     * Lorsque l'on sauvegarde l'image
+     */
+    const save = async () => {
+      fileToSave.config = JSON.stringify({
+        x: coordinates.value.left,
+        y: coordinates.value.top,
+        height: coordinates.value.height,
+        width: coordinates.value.width
+      })
+
+      //Cas d'un PUT : l'image existe déjà on bouge simplement le cropper
+      if(image.value.id){
+        fileToSave.id = image.value.id
+        repositoryHelper.persist(File, fileToSave)
+        await $dataPersister.invoke({
+          type: QUERY_TYPE.MODEL,
+          model: File,
+          id: fileToSave.id
+        })
+        //On émet un évent afin de mettre à jour le formulaire de départ
+        emit('reload')
+      }
+
+      //Post : on créer une nouvelle image donc on passe par l'api legacy...
+      else{
+        if(image.value.file){
+          //On créer l'objet File à sauvegarder
+          fileToSave.name = image.value.name
+          fileToSave.imgFieldName = props.field
+          fileToSave.visibility = 'EVERYBODY'
+          fileToSave.folder = 'IMAGES'
+
+          if(props.ownerId)
+            fileToSave.ownerId = props.ownerId
+
+          //Appel au datapersister
+          const response: ApiResponse = await $dataPersister.invoke({
+            type: QUERY_TYPE.FILE,
+            baseUrl: $config.baseURL_Legacy,
+            data: fileToSave.$toJson(),
+            file: image.value.file
+          })
+          //On émet un évent afin de mettre à jour le formulaire de départ
+          emit('update', response.data['@id'])
+        }else{
+          //On reset l'image : on a appuyer sur "poubelle" puis on enregistre
+          emit('reset')
+        }
+      }
+    }
+
+    /**
+     * On choisit de supprimer l'image présente
+     */
+    const reset = () => {
+      image.value.src = null
+      image.value.file = null
+      image.value.name = null
+      image.value.id = null
+      URL.revokeObjectURL(image.value.src)
+    }
+
+    /**
+     * Lorsqu'on démonte le component on supprime le watcher et on revoke l'objet URL
+     */
+    onUnmounted(() => {
+      unwatch()
+        if (image.value.src) {
+          URL.revokeObjectURL(image.value.src)
+        }
+    })
+
+    return {
+      image,
+      save,
+      loadImage,
+      cropper,
+      reset,
+      fetchState,
+      coordinates,
+      onChange,
+      defaultSize
+    }
+  }
+})
+
+</script>
+
+<style lang="scss">
+  .vue-advanced-cropper__stretcher{
+    height: auto !important;
+    width: auto !important;
+  }
+  .loading{
+    height: 300px;
+  }
+  .upload {
+    user-select: none;
+    padding: 20px;
+    display: block;
+    &__cropper {
+       border: solid 1px #eee;
+       min-height: 500px;
+       max-height: 500px;
+     }
+    &__cropper-wrapper {
+       position: relative;
+     }
+    &__reset-button {
+      position: absolute;
+      right: 20px;
+      bottom: 20px;
+      cursor: pointer;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      height: 42px;
+      width: 42px;
+      background: rgba(#3fb37f, 0.7);
+      transition: background 0.5s;
+      &:hover {
+        background: #3fb37f;
+      }
+    }
+    &__buttons-wrapper {
+       display: flex;
+       justify-content: center;
+       margin-top: 17px;
+     }
+    &__button {
+       border: none;
+       outline: solid transparent;
+       color: white;
+       font-size: 16px;
+       padding: 10px 20px;
+       background: #3fb37f;
+       cursor: pointer;
+       transition: background 0.5s;
+       margin: 0 16px;
+      &:hover,
+      &:focus {
+         background: #38d890;
+       }
+      input {
+        display: none;
+      }
+    }
+  }
+</style>

+ 26 - 0
models/Core/File.ts

@@ -0,0 +1,26 @@
+import {Str, Model, Uid, Num} from '@vuex-orm/core'
+
+export class File extends Model {
+  static entity = 'files'
+
+  @Uid()
+  id!: number | string | null
+
+  @Str('')
+  name!: string
+
+  @Str('')
+  imgFieldName!: string
+
+  @Str('')
+  visibility!: string
+
+  @Str('')
+  config!: string
+
+  @Str('')
+  folder!: string
+
+  @Num(null, { nullable: true })
+  ownerId!: number
+}

+ 28 - 5
services/connection/urlBuilder.ts

@@ -1,5 +1,5 @@
 import {Model} from '@vuex-orm/core'
-import {ImageArgs, UrlArgs} from '~/types/interfaces'
+import {DataPersisterArgs, DataProviderArgs, ImageArgs, UrlArgs} from '~/types/interfaces'
 import {QUERY_TYPE} from '~/types/enums'
 import {repositoryHelper} from '~/services/store/repository'
 import TypesTesting from "~/services/utils/typesTesting";
@@ -20,10 +20,7 @@ class UrlBuilder {
     let url: string = ''
     switch (args.type) {
       case QUERY_TYPE.DEFAULT:
-        if (!args.url) {
-          throw new Error('*args* has no url')
-        }
-        url = args.url
+        url = UrlBuilder.getDefaultUrl( args.url, args.baseUrl)
         break;
 
       case QUERY_TYPE.ENUM:
@@ -44,6 +41,10 @@ class UrlBuilder {
         url = UrlBuilder.getImageUrl(args.imgArgs, args.baseUrl)
         break;
 
+      case QUERY_TYPE.FILE:
+        url = UrlBuilder.getFileUrl(args.baseUrl)
+        break;
+
       default:
         throw new Error('url, model, image or enum must be defined')
         break;
@@ -53,6 +54,19 @@ class UrlBuilder {
     return options.length > 0 ? `${url}?${UrlOptionsBuilder.build(args).join('&')}` : url
   }
 
+  /**
+   * Construction d'une URL qui ira concaténer la base URL avec l'url
+   * @param url
+   * @param baseUrl
+   * @private
+   */
+  private static getDefaultUrl (url?: string, baseUrl: string = ''): string {
+    if (!url) {
+      throw new Error('no url')
+    }
+    return UrlBuilder.concat(baseUrl, url)
+  }
+
   /**
    * Construction d'une URL Type Enum qui ira concaténer le type enum passé en paramètre avec la ROOT Url définie
    * @param {string} enumType
@@ -105,6 +119,15 @@ class UrlBuilder {
     return UrlBuilder.concat(baseUrl, UrlBuilder.ROOT, downloadUrl)
   }
 
+  /**
+   * Construction d'une URL qui ira concaténer la base URL avec le Root et l'uri files
+   * @param baseUrl
+   * @private
+   */
+  private static getFileUrl (baseUrl: string = ''): string {
+    return UrlBuilder.concat(baseUrl, UrlBuilder.ROOT, 'files')
+  }
+
   /**
    * Concatenate a base url and a tail
    * @param base

+ 2 - 0
services/data/processor/_import.ts

@@ -2,10 +2,12 @@ import ModelProcessor from '~/services/data/processor/modelProcessor'
 import EnumProcessor from '~/services/data/processor/enumProcessor'
 import DefaultProcessor from '~/services/data/processor/defaultProcessor'
 import ImageProcessor from "~/services/data/processor/imageProcessor";
+import FileProcessor from "~/services/data/processor/fileProcessor";
 
 export const processors = [
   DefaultProcessor,
   ModelProcessor,
   EnumProcessor,
+  FileProcessor,
   ImageProcessor
 ]

+ 24 - 0
services/data/processor/fileProcessor.ts

@@ -0,0 +1,24 @@
+import { AnyJson, DataProviderArgs, Processor } from '~/types/interfaces'
+import BaseProcessor from '~/services/data/processor/baseProcessor'
+import { QUERY_TYPE } from '~/types/enums'
+
+class FileProcessor extends BaseProcessor implements Processor {
+  /**
+   * Is the given argument a supported model
+   * @param args
+   */
+  public static support (args: DataProviderArgs): boolean {
+    return args.type === QUERY_TYPE.FILE
+  }
+
+  /**
+   *
+   * @param data
+   */
+  // eslint-disable-next-line require-await
+  async process (data: AnyJson): Promise<any> {
+    return data
+  }
+}
+
+export default FileProcessor

+ 5 - 1
services/serializer/normalizer/_import.ts

@@ -1,5 +1,9 @@
+import Default from '~/services/serializer/normalizer/default'
 import Model from '~/services/serializer/normalizer/model'
+import File from '~/services/serializer/normalizer/file'
 
 export const normalizers = [
-  Model
+  Default,
+  Model,
+  File
 ]

+ 28 - 0
services/serializer/normalizer/default.ts

@@ -0,0 +1,28 @@
+import BaseNormalizer from '~/services/serializer/normalizer/baseNormalizer'
+import {DataPersisterArgs} from '~/types/interfaces'
+import { QUERY_TYPE } from '~/types/enums'
+
+/**
+ * @category Services/serializer/normalizer
+ * @class Default
+ * Classe assurant la normalization par défaut
+ */
+class Default extends BaseNormalizer {
+  static support (type: QUERY_TYPE): boolean {
+    return type === QUERY_TYPE.DEFAULT
+  }
+
+  /**
+   * On renvoi les datas a persister
+   * @param {DataPersisterArgs} args
+   * @return {any} réponse
+   */
+  public static normalize (args: DataPersisterArgs): any {
+    if (!args.data) {
+      throw new Error('*args* has no data attribute')
+    }
+
+    return args.data
+  }
+}
+export default Default

+ 34 - 0
services/serializer/normalizer/file.ts

@@ -0,0 +1,34 @@
+import BaseNormalizer from '~/services/serializer/normalizer/baseNormalizer'
+import {DataPersisterArgs} from '~/types/interfaces'
+import { QUERY_TYPE } from '~/types/enums'
+
+/**
+ * @category Services/serializer/normalizer
+ * @class Default
+ * Classe assurant la normalization par défaut
+ */
+class File extends BaseNormalizer {
+  static support (type: QUERY_TYPE): boolean {
+    return type === QUERY_TYPE.FILE
+  }
+
+  /**
+   * On transforme les data en FormData et on les renvois
+   * @param {DataPersisterArgs} args
+   * @return {any} réponse
+   */
+  public static normalize (args: DataPersisterArgs): any {
+    if (!args.data) {
+      throw new Error('*args* has no data attribute')
+    }
+
+    const fileData = new FormData();
+    for(const key in args.data){
+      fileData.set(key, args.data[key])
+    }
+
+    fileData.set('file', args.file as string)
+    return fileData
+  }
+}
+export default File

+ 64 - 0
use/data/useImage.ts

@@ -0,0 +1,64 @@
+import {AnyJson, ApiResponse} from '~/types/interfaces'
+import {QUERY_TYPE} from "~/types/enums";
+import {useContext, useFetch, Ref, ref} from '@nuxtjs/composition-api'
+import DataProvider from "~/services/data/dataProvider";
+
+/**
+ * @category Use/data
+ * @class UseImage
+ * Use Classe qui va récupérer les Images
+ */
+export class UseImage {
+  private $dataProvider!: DataProvider
+  private $config!: AnyJson
+
+  constructor() {
+    const {$dataProvider, $config} = useContext()
+    this.$dataProvider = $dataProvider
+    this.$config = $config
+  }
+
+  /**
+   * Récupération d'une image via l'ancienne API
+   */
+  public getOne(id: number|undefined, imageByDefault: string = '', height: number|undefined = 0, width: number|undefined = 0): AnyJson{
+    const imageLoaded: Ref<String> = ref('')
+
+    const {fetchState, fetch} = useFetch(async () => {
+        try{
+          if(id){
+            imageLoaded.value = await this.provideImg(id, height, width)
+          }else
+            throw new Error('id is null')
+        }catch (e){
+          if(imageByDefault)
+            imageLoaded.value = require(`assets/images/byDefault/${imageByDefault}`)
+        }
+      }
+    )
+
+    return {
+      fetch,
+      fetchState,
+      imageLoaded
+    }
+  }
+
+  /**
+   * retourne l'image demandée
+   * @param id
+   * @param height
+   * @param width
+   */
+  public async provideImg(id: number, height: number = 0, width: number = 0){
+    return await this.$dataProvider.invoke({
+      type: QUERY_TYPE.IMAGE,
+      baseUrl: this.$config.baseURL_Legacy,
+      imgArgs: {
+        id: id,
+        height: height,
+        width: width
+      }
+    })
+  }
+}