mirror of
				https://github.com/crater-invoice/crater.git
				synced 2025-10-28 20:21:10 -04:00 
			
		
		
		
	
		
			
				
	
	
		
			586 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
			
		
		
	
	
			586 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
| <template>
 | |
|   <form
 | |
|     enctype="multipart/form-data"
 | |
|     class="
 | |
|       relative
 | |
|       flex
 | |
|       items-center
 | |
|       justify-center
 | |
|       p-2
 | |
|       border-2 border-dashed
 | |
|       rounded-md
 | |
|       cursor-pointer
 | |
|       avatar-upload
 | |
|       border-gray-200
 | |
|       transition-all
 | |
|       duration-300
 | |
|       ease-in-out
 | |
|       isolate
 | |
|       w-full
 | |
|       hover:border-gray-300
 | |
|       group
 | |
|       min-h-[100px]
 | |
|       bg-gray-50
 | |
|       dark:bg-gray-700 dark:border-gray-600
 | |
|     "
 | |
|     :class="avatar ? 'w-32 h-32' : 'w-full'"
 | |
|   >
 | |
|     <input
 | |
|       id="file-upload"
 | |
|       ref="inputRef"
 | |
|       type="file"
 | |
|       tabindex="-1"
 | |
|       :multiple="multiple"
 | |
|       :name="inputFieldName"
 | |
|       :accept="accept"
 | |
|       class="absolute z-10 w-full h-full opacity-0 cursor-pointer"
 | |
|       @click="$event.target.value=null"
 | |
|       @change="
 | |
|         onChange(
 | |
|           $event.target.name,
 | |
|           $event.target.files,
 | |
|           $event.target.files.length
 | |
|         )
 | |
|       "
 | |
|     />
 | |
| 
 | |
|     <!-- Avatar Not Selected -->
 | |
|     <div v-if="!localFiles.length && avatar" class="">
 | |
|       <img :src="getDefaultAvatar()" class="rounded" alt="Default Avatar" />
 | |
| 
 | |
|       <a
 | |
|         href="#"
 | |
|         class="absolute z-30 bg-white rounded-full -bottom-3 -right-3 group dark:bg-gray-900"
 | |
|         @click.prevent.stop="onBrowse"
 | |
|       >
 | |
|         <BaseIcon
 | |
|           name="PlusCircleIcon"
 | |
|           class="
 | |
|             h-8
 | |
|             text-xl
 | |
|             leading-6
 | |
|             text-primary-500
 | |
|             group-hover:text-primary-600
 | |
|           "
 | |
|         />
 | |
|       </a>
 | |
|     </div>
 | |
| 
 | |
|     <!-- Not Selected -->
 | |
|     <div v-else-if="!localFiles.length" class="flex flex-col items-center">
 | |
|       <BaseIcon
 | |
|         name="CloudUploadIcon"
 | |
|         class="h-6 mb-2 text-xl leading-6 text-gray-400"
 | |
|       />
 | |
|       <p class="text-xs leading-4 text-center text-gray-400">
 | |
|         Drag a file here or
 | |
|         <a
 | |
|           class="
 | |
|             cursor-pointer
 | |
|             text-primary-500
 | |
|             hover:text-primary-600 hover:font-medium
 | |
|             relative
 | |
|             z-20
 | |
|           "
 | |
|           href="#"
 | |
|           @click.prevent.stop="onBrowse"
 | |
|         >
 | |
|           browse
 | |
|         </a>
 | |
|         to choose a file
 | |
|       </p>
 | |
|       <p class="text-xs leading-4 text-center text-gray-400 mt-2">
 | |
|         {{ recommendedText }}
 | |
|       </p>
 | |
|     </div>
 | |
| 
 | |
|     <div
 | |
|       v-else-if="localFiles.length && avatar && !multiple"
 | |
|       class="flex w-full h-full border border-gray-200 rounded dark:border-gray-600"
 | |
|     >
 | |
|       <img
 | |
|         v-if="localFiles[0].image"
 | |
|         for="file-upload"
 | |
|         :src="localFiles[0].image"
 | |
|         class="block object-cover w-full h-full rounded opacity-100"
 | |
|         style="animation: fadeIn 2s ease"
 | |
|       />
 | |
| 
 | |
|       <div
 | |
|         v-else
 | |
|         class="
 | |
|           flex
 | |
|           justify-center
 | |
|           items-center
 | |
|           text-gray-400
 | |
|           flex-col
 | |
|           space-y-2
 | |
|           px-2
 | |
|           py-4
 | |
|           w-full
 | |
|         "
 | |
|       >
 | |
|         <!-- DocumentText Icon -->
 | |
|         <svg
 | |
|           xmlns="http://www.w3.org/2000/svg"
 | |
|           class="h-8 w-8"
 | |
|           fill="none"
 | |
|           viewBox="0 0 24 24"
 | |
|           stroke="currentColor"
 | |
|         >
 | |
|           <path
 | |
|             stroke-linecap="round"
 | |
|             stroke-linejoin="round"
 | |
|             stroke-width="1.25"
 | |
|             d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
 | |
|           />
 | |
|         </svg>
 | |
| 
 | |
|         <p
 | |
|           v-if="localFiles[0].name"
 | |
|           class="
 | |
|             text-gray-600
 | |
|             font-medium
 | |
|             text-sm
 | |
|             truncate
 | |
|             overflow-hidden
 | |
|             w-full
 | |
|           "
 | |
|         >
 | |
|           {{ localFiles[0].name }}
 | |
|         </p>
 | |
|       </div>
 | |
| 
 | |
|       <a
 | |
|         href="#"
 | |
|         class="
 | |
|           box-border
 | |
|           absolute
 | |
|           z-30
 | |
|           flex
 | |
|           items-center
 | |
|           justify-center
 | |
|           w-8
 | |
|           h-8
 | |
|           bg-white
 | |
|           border border-gray-200
 | |
|           rounded-full
 | |
|           shadow-md
 | |
|           -bottom-3
 | |
|           -right-3
 | |
|           group
 | |
|           hover:border-gray-300
 | |
|           dark:border-gray-600 dark:bg-gray-900 dark:hover:border-gray-700
 | |
|         "
 | |
|         @click.prevent.stop="onAvatarRemove(localFiles[0])"
 | |
|       >
 | |
|         <BaseIcon name="XIcon" class="h-4 text-xl leading-6 text-black dark:text-white" />
 | |
|       </a>
 | |
|     </div>
 | |
| 
 | |
|     <!-- Preview Files Multiple -->
 | |
|     <div
 | |
|       v-else-if="localFiles.length && multiple"
 | |
|       class="flex flex-wrap w-full"
 | |
|     >
 | |
|       <a
 | |
|         v-for="(localFile, index) in localFiles"
 | |
|         :key="localFile"
 | |
|         href="#"
 | |
|         class="
 | |
|           block
 | |
|           p-2
 | |
|           m-2
 | |
|           bg-white
 | |
|           border border-gray-200
 | |
|           rounded
 | |
|           hover:border-gray-500
 | |
|           relative
 | |
|           max-w-md
 | |
|           dark:border-gray-600 dark:bg-transparent dark:hover:border-gray-700
 | |
|         "
 | |
|         @click.prevent
 | |
|       >
 | |
|         <img
 | |
|           v-if="localFile.image"
 | |
|           for="file-upload"
 | |
|           :src="localFile.image"
 | |
|           class="block object-cover w-20 h-20 opacity-100"
 | |
|           style="animation: fadeIn 2s ease"
 | |
|         />
 | |
| 
 | |
|         <div
 | |
|           v-else
 | |
|           class="
 | |
|             flex
 | |
|             justify-center
 | |
|             items-center
 | |
|             text-gray-400
 | |
|             flex-col
 | |
|             space-y-2
 | |
|             px-2
 | |
|             py-4
 | |
|             w-full
 | |
|           "
 | |
|         >
 | |
|           <!-- DocumentText Icon -->
 | |
|           <svg
 | |
|             xmlns="http://www.w3.org/2000/svg"
 | |
|             class="h-8 w-8"
 | |
|             fill="none"
 | |
|             viewBox="0 0 24 24"
 | |
|             stroke="currentColor"
 | |
|           >
 | |
|             <path
 | |
|               stroke-linecap="round"
 | |
|               stroke-linejoin="round"
 | |
|               stroke-width="1.25"
 | |
|               d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
 | |
|             />
 | |
|           </svg>
 | |
| 
 | |
|           <p
 | |
|             v-if="localFile.name"
 | |
|             class="
 | |
|               text-gray-600
 | |
|               font-medium
 | |
|               text-sm
 | |
|               truncate
 | |
|               overflow-hidden
 | |
|               w-full
 | |
|             "
 | |
|           >
 | |
|             {{ localFile.name }}
 | |
|           </p>
 | |
|         </div>
 | |
| 
 | |
|         <a
 | |
|           href="#"
 | |
|           class="
 | |
|             box-border
 | |
|             absolute
 | |
|             z-30
 | |
|             flex
 | |
|             items-center
 | |
|             justify-center
 | |
|             w-8
 | |
|             h-8
 | |
|             bg-white
 | |
|             border border-gray-200
 | |
|             rounded-full
 | |
|             shadow-md
 | |
|             -bottom-3
 | |
|             -right-3
 | |
|             group
 | |
|             hover:border-gray-300
 | |
|             dark:border-gray-600 dark:bg-gray-900 dark:hover:border-gray-700
 | |
|           "
 | |
|           @click.prevent.stop="onFileRemove(index)"
 | |
|         >
 | |
|           <BaseIcon name="XIcon" class="h-4 text-xl leading-6 text-black" />
 | |
|         </a>
 | |
|       </a>
 | |
|     </div>
 | |
| 
 | |
|     <div v-else class="flex w-full items-center justify-center">
 | |
|       <a
 | |
|         v-for="(localFile, index) in localFiles"
 | |
|         :key="localFile"
 | |
|         href="#"
 | |
|         class="
 | |
|           block
 | |
|           p-2
 | |
|           m-2
 | |
|           bg-white
 | |
|           border border-gray-200
 | |
|           rounded
 | |
|           hover:border-gray-500
 | |
|           relative
 | |
|           max-w-md
 | |
|           dark:border-gray-600 dark:bg-gray-800 dark:hover:border-gray-700
 | |
|         "
 | |
|         @click.prevent
 | |
|       >
 | |
|         <img
 | |
|           v-if="localFile.image"
 | |
|           for="file-upload"
 | |
|           :src="localFile.image"
 | |
|           class="block object-contain h-20 opacity-100 min-w-[5rem]"
 | |
|           style="animation: fadeIn 2s ease"
 | |
|         />
 | |
| 
 | |
|         <div
 | |
|           v-else
 | |
|           class="
 | |
|             flex
 | |
|             justify-center
 | |
|             items-center
 | |
|             text-gray-400
 | |
|             flex-col
 | |
|             space-y-2
 | |
|             px-2
 | |
|             py-4
 | |
|             w-full
 | |
|           "
 | |
|         >
 | |
|           <!-- DocumentText Icon -->
 | |
|           <svg
 | |
|             xmlns="http://www.w3.org/2000/svg"
 | |
|             class="h-8 w-8"
 | |
|             fill="none"
 | |
|             viewBox="0 0 24 24"
 | |
|             stroke="currentColor"
 | |
|           >
 | |
|             <path
 | |
|               stroke-linecap="round"
 | |
|               stroke-linejoin="round"
 | |
|               stroke-width="1.25"
 | |
|               d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
 | |
|             />
 | |
|           </svg>
 | |
| 
 | |
|           <p
 | |
|             v-if="localFile.name"
 | |
|             class="
 | |
|               text-gray-600
 | |
|               font-medium
 | |
|               text-sm
 | |
|               truncate
 | |
|               overflow-hidden
 | |
|               w-full
 | |
|             "
 | |
|           >
 | |
|             {{ localFile.name }}
 | |
|           </p>
 | |
|         </div>
 | |
| 
 | |
|         <a
 | |
|           href="#"
 | |
|           class="
 | |
|             box-border
 | |
|             absolute
 | |
|             z-30
 | |
|             flex
 | |
|             items-center
 | |
|             justify-center
 | |
|             w-8
 | |
|             h-8
 | |
|             bg-white
 | |
|             border border-gray-200
 | |
|             rounded-full
 | |
|             shadow-md
 | |
|             -bottom-3
 | |
|             -right-3
 | |
|             group
 | |
|             hover:border-gray-300
 | |
|             dark:border-gray-600 dark:bg-gray-900 dark:hover:border-gray-700
 | |
|           "
 | |
|           @click.prevent.stop="onFileRemove(index)"
 | |
|         >
 | |
|           <BaseIcon name="XIcon" class="h-4 text-xl leading-6 text-black dark:text-white" />
 | |
|         </a>
 | |
|       </a>
 | |
|     </div>
 | |
|   </form>
 | |
| </template>
 | |
| 
 | |
| <script setup>
 | |
| import { ref, onMounted, watch } from 'vue'
 | |
| import axios from 'axios'
 | |
| import utils from '@/scripts/helpers/utilities'
 | |
| 
 | |
| const props = defineProps({
 | |
|   multiple: {
 | |
|     type: Boolean,
 | |
|     default: false,
 | |
|   },
 | |
|   avatar: {
 | |
|     type: Boolean,
 | |
|     default: false,
 | |
|   },
 | |
|   autoProcess: {
 | |
|     type: Boolean,
 | |
|     default: false,
 | |
|   },
 | |
|   uploadUrl: {
 | |
|     type: String,
 | |
|     default: '',
 | |
|   },
 | |
|   preserveLocalFiles: {
 | |
|     type: Boolean,
 | |
|     default: false,
 | |
|   },
 | |
|   accept: {
 | |
|     type: String,
 | |
|     default: 'image/*',
 | |
|   },
 | |
|   inputFieldName: {
 | |
|     type: String,
 | |
|     default: 'photos',
 | |
|   },
 | |
|   base64: {
 | |
|     type: Boolean,
 | |
|     default: false,
 | |
|   },
 | |
|   modelValue: {
 | |
|     type: Array,
 | |
|     default: () => [],
 | |
|   },
 | |
|   recommendedText: {
 | |
|     type: String,
 | |
|     default: '',
 | |
|   },
 | |
| })
 | |
| 
 | |
| const emit = defineEmits(['change', 'remove', 'update:modelValue'])
 | |
| 
 | |
| // status
 | |
| const STATUS_INITIAL = 0
 | |
| const STATUS_SAVING = 1
 | |
| const STATUS_SUCCESS = 2
 | |
| const STATUS_FAILED = 3
 | |
| 
 | |
| let uploadedFiles = ref([])
 | |
| const localFiles = ref([])
 | |
| const inputRef = ref(null)
 | |
| let uploadError = ref(null)
 | |
| let currentStatus = ref(null)
 | |
| 
 | |
| function reset() {
 | |
|   // reset form to initial state
 | |
|   currentStatus = STATUS_INITIAL
 | |
| 
 | |
|   uploadedFiles.value = []
 | |
| 
 | |
|   if (props.modelValue && props.modelValue.length) {
 | |
|     localFiles.value = [...props.modelValue]
 | |
|   } else {
 | |
|     localFiles.value = []
 | |
|   }
 | |
| 
 | |
|   uploadError = null
 | |
| }
 | |
| 
 | |
| function upload(formData) {
 | |
|   return (
 | |
|     axios
 | |
|       .post(props.uploadUrl, formData)
 | |
|       // get data
 | |
|       .then((x) => x.data)
 | |
|       // add url field
 | |
|       .then((x) => x.map((img) => ({ ...img, url: `/images/${img.id}` })))
 | |
|   )
 | |
| }
 | |
| 
 | |
| // upload data to the server
 | |
| function save(formData) {
 | |
|   currentStatus = STATUS_SAVING
 | |
| 
 | |
|   upload(formData)
 | |
|     .then((x) => {
 | |
|       uploadedFiles = [].concat(x)
 | |
|       currentStatus = STATUS_SUCCESS
 | |
|     })
 | |
|     .catch((err) => {
 | |
|       uploadError = err.response
 | |
|       currentStatus = STATUS_FAILED
 | |
|     })
 | |
| }
 | |
| 
 | |
| function getBase64(file) {
 | |
|   return new Promise((resolve, reject) => {
 | |
|     const reader = new FileReader()
 | |
|     reader.readAsDataURL(file)
 | |
|     reader.onload = () => resolve(reader.result)
 | |
|     reader.onerror = (error) => reject(error)
 | |
|   })
 | |
| }
 | |
| 
 | |
| function onChange(fieldName, fileList, fileCount) {
 | |
|   if (!fileList.length) return
 | |
| 
 | |
|   if (props.multiple) {
 | |
|     emit('change', fieldName, fileList, fileCount)
 | |
|   } else {
 | |
|     if (props.base64) {
 | |
|       getBase64(fileList[0]).then((res) => {
 | |
|         emit('change', fieldName, res, fileCount, fileList[0])
 | |
|       })
 | |
|     } else {
 | |
|       emit('change', fieldName, fileList[0], fileCount)
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   if (!props.preserveLocalFiles) {
 | |
|     localFiles.value = []
 | |
|   }
 | |
| 
 | |
|   Array.from(Array(fileList.length).keys()).forEach((x) => {
 | |
|     const file = fileList[x]
 | |
| 
 | |
|     if (utils.isImageFile(file.type)) {
 | |
|       getBase64(file).then((image) => {
 | |
|         localFiles.value.push({
 | |
|           fileObject: file,
 | |
|           type: file.type,
 | |
|           name: file.name,
 | |
|           image,
 | |
|         })
 | |
|       })
 | |
|     } else {
 | |
|       localFiles.value.push({
 | |
|         fileObject: file,
 | |
|         type: file.type,
 | |
|         name: file.name,
 | |
|       })
 | |
|     }
 | |
|   })
 | |
| 
 | |
|   emit('update:modelValue', localFiles.value)
 | |
| 
 | |
|   if (!props.autoProcess) return
 | |
| 
 | |
|   // append the files to FormData
 | |
|   const formData = new FormData()
 | |
| 
 | |
|   Array.from(Array(fileList.length).keys()).forEach((x) => {
 | |
|     formData.append(fieldName, fileList[x], fileList[x].name)
 | |
|   })
 | |
| 
 | |
|   // save it
 | |
|   save(formData)
 | |
| }
 | |
| 
 | |
| function onBrowse() {
 | |
|   if (inputRef.value) {
 | |
|     inputRef.value.click()
 | |
|   }
 | |
| }
 | |
| 
 | |
| function onAvatarRemove(image) {
 | |
|   localFiles.value = []
 | |
|   emit('remove', image)
 | |
| }
 | |
| 
 | |
| function onFileRemove(index) {
 | |
|   localFiles.value.splice(index, 1)
 | |
|   emit('remove', index)
 | |
| }
 | |
| 
 | |
| function getDefaultAvatar() {
 | |
|   const imgUrl = new URL('/img/default-avatar.jpg', import.meta.url)
 | |
|   return imgUrl
 | |
| }
 | |
| 
 | |
| onMounted(() => {
 | |
|   reset()
 | |
| })
 | |
| 
 | |
| watch(
 | |
|   () => props.modelValue,
 | |
|   (v) => {
 | |
|     localFiles.value = [...v]
 | |
|   }
 | |
| )
 | |
| </script>
 |