@westartcom/bread-quasar-fields 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,431 @@
1
+ <template>
2
+ <div class="field-wrap" :id="id">
3
+ <div class="field-wrap-inner">
4
+ <div class="field-label-wrap">
5
+ <label v-if="field.label" :for="id + '-input'">{{ field.label }}<span class="required-asterix" v-if="field.required"> *</span></label>
6
+ </div>
7
+
8
+ <div class="images-upload">
9
+ <div v-show="canAdd">
10
+ <button type="button" class="btn hollow" @click="$refs.imageUpload.click()">
11
+ <template v-if="allowMultiple">
12
+ {{ $i18n.get('actions.add_images') }}
13
+ </template>
14
+ <template v-else>
15
+ {{ $i18n.get('actions.add_image') }}
16
+ </template>
17
+ </button>
18
+ <input
19
+ ref="imageUpload"
20
+ :id="id + '-input'"
21
+ type="file"
22
+ style="display: none;"
23
+ :accept="allowedMimeTypes"
24
+ :multiple="allowMultiple"
25
+ @change="onImageSelect($event.target.files)">
26
+ </div>
27
+
28
+ <div class="added-images">
29
+ <div class="image" v-for="(image, index) in addedTusImages" :key="index" @click="onRemoveImage(index, image, true)">
30
+ <div class="remove-overlay">
31
+ <delete-icon class="icon-2x"></delete-icon>
32
+ </div>
33
+ <div class="upload-error" v-show="image.error">
34
+ <upload-error-icon class="icon-2x"></upload-error-icon>
35
+ </div>
36
+ <div class="upload-progress" v-show="image.progress < 100">
37
+ <div class="progress-bar" :style="{ width: image.progress + '%' }"></div>
38
+ </div>
39
+ <img :src="safeUrl(image)">
40
+ </div>
41
+
42
+ <div
43
+ v-for="(image, index) in addedImages"
44
+ :key="index"
45
+ class="image"
46
+ @click="onRemoveImage(index, image)">
47
+ <div class="remove-overlay">
48
+ <q-icon name="fa-solid fa-trash"></q-icon>
49
+ </div>
50
+ <img :src="safeUrl(image)">
51
+ </div>
52
+ </div>
53
+ </div>
54
+ </div>
55
+
56
+ <div class="field-description" v-if="field.description">{{ field.description }}</div>
57
+
58
+ <div class="validation-errors" v-if="invalid && internalErrors && internalErrors.length">
59
+ <span class="error danger-txt-color" v-for="(error, index) in internalErrors" :key="index + 'error'">{{ error }}</span>
60
+ </div>
61
+ </div>
62
+ </template>
63
+
64
+ <script lang="ts">
65
+ import { defineAsyncComponent, defineComponent, PropType } from 'vue';
66
+ import { ModelFieldDefinition } from '@westartcom/bread-quasar-fields/interfaces/ModelDefinition';
67
+ import { FieldType } from '@westartcom/bread-quasar-fields/enums/FieldType';
68
+ import { InputType } from '@westartcom/bread-quasar-fields/enums/InputType';
69
+ import { UploadImage } from '@westartcom/bread-quasar-fields/interfaces/UploadImage';
70
+ // import * as tus from 'tus-js-client';
71
+
72
+ interface ImageUploadFieldDefinition extends ModelFieldDefinition<FieldType.MEDIA, InputType.IMAGE_UPLOAD> {
73
+ default: string | null;
74
+ extra_data: {
75
+ media_type: 'images';
76
+ tus: boolean;
77
+ tus_endpoint: string;
78
+ mime_types: string[];
79
+ multiple: boolean;
80
+ collection: string;
81
+ }
82
+ }
83
+
84
+ export default defineComponent({
85
+ components: {
86
+ DeleteIcon: defineAsyncComponent(() => import('@westartcom/bread-quasar-fields/icons/DeleteIcon.vue')),
87
+ UploadErrorIcon: defineAsyncComponent(() => import('@westartcom/bread-quasar-fields/icons/FileAlertOutlineIcon.vue'))
88
+ },
89
+
90
+ props: {
91
+ id: {
92
+ type: String,
93
+ required: true
94
+ },
95
+ field: {
96
+ type: Object as PropType<ImageUploadFieldDefinition>,
97
+ required: true
98
+ },
99
+ modelValue: Array,
100
+ errors: Array as PropType<string[]>
101
+ },
102
+
103
+ emits: [
104
+ 'update:modelValue',
105
+ 'update:invalid',
106
+ 'update:uploading'
107
+ ],
108
+
109
+ data() {
110
+ return {
111
+ invalid: null as boolean | null,
112
+ images: [] as UploadImage[],
113
+ tusImages: [] as UploadImage[],
114
+ internalErrors: [],
115
+ tusUploads: {} as {
116
+ [key: string]: any;
117
+ }
118
+ };
119
+ },
120
+
121
+ computed : {
122
+ allowMultiple() {
123
+ return (this.field.extra_data && this.field.extra_data.multiple);
124
+ },
125
+
126
+ allowedMimeTypes() {
127
+ return ((this.field.extra_data?.mime_types) ? this.field.extra_data.mime_types : ['image/*']).join('|');
128
+ },
129
+
130
+ canAdd() {
131
+ return this.allowMultiple || !this.images.filter(image => !image.remove).length;
132
+ },
133
+
134
+ useTus() {
135
+ return (this.field.extra_data && this.field.extra_data.tus);
136
+ },
137
+
138
+ addedImages() {
139
+ return this.images.filter(image => !image.remove && (image.url || image.base64));
140
+ },
141
+
142
+ addedTusImages() {
143
+ return this.tusImages.filter(image => !image.remove);
144
+ }
145
+ },
146
+
147
+ mounted() {
148
+ this.tusUploads = {};
149
+
150
+ if (this.modelValue) {
151
+ this.images = (Array.isArray(this.modelValue)) ? this.modelValue : [this.modelValue];
152
+ } else if (this.field.default && Array.isArray(this.field.default)) {
153
+ this.images = this.field.default;
154
+ }
155
+
156
+ this.validate(this.images);
157
+ },
158
+
159
+ watch: {
160
+ modelValue: {
161
+ handler: function(val) {
162
+ if (
163
+ val &&
164
+ Array.isArray(val)
165
+ ) {
166
+ this.images = val;
167
+ if (this.tusImages.length) {
168
+ this.tusImages = this.tusImages.filter(tusImage => {
169
+ return !tusImage.tusKey || val.some(
170
+ upstreamImage => (upstreamImage.tusKey && upstreamImage.tusKey === tusImage.tusKey));
171
+ });
172
+ }
173
+ } else {
174
+ this.images = [];
175
+ this.tusImages = [];
176
+ (this.$refs.imageUpload as any).value = '';
177
+ }
178
+ },
179
+ deep: true
180
+ },
181
+
182
+ images: {
183
+ handler: function(val) {
184
+ this.validate(val);
185
+ this.$emit('update:modelValue', val);
186
+ },
187
+ deep: true
188
+ },
189
+
190
+ errors: {
191
+ handler: function(val) {
192
+ this.internalErrors = val;
193
+ },
194
+ deep: true
195
+ },
196
+
197
+ internalErrors: {
198
+ handler: function(val) {
199
+ this.invalid = !!(val && val.length);
200
+ },
201
+ deep: true
202
+ },
203
+
204
+ invalid: function(val) {
205
+ this.$emit('update:invalid', val);
206
+ },
207
+
208
+ tusImages: {
209
+ handler: function(val) {
210
+ if (val.some(image => image.progress < 100)) {
211
+ this.$emit('update:uploading', true);
212
+ } else {
213
+ this.$emit('update:uploading', false);
214
+ }
215
+ },
216
+ deep: true
217
+ }
218
+ },
219
+
220
+ methods: {
221
+ async onImageSelect(files: FileList) {
222
+ if (files.length) {
223
+ for (let i = 0; i < files.length; i++) {
224
+ const file = files.item(i);
225
+
226
+ if (!file) {
227
+ continue;
228
+ }
229
+
230
+ if (this.useTus) {
231
+ await this.startTus(file);
232
+ } else {
233
+ this.addImage(await this.getBase64(file));
234
+ }
235
+ }
236
+ }
237
+ },
238
+
239
+ async startTus(file: File) {
240
+ throw 'TUS not implemented';
241
+ // const vueInstance = this;
242
+ // const tempKey = Date.now().toString() + '/' + file.name;
243
+ //
244
+ // this.tusImages.push({
245
+ // tempKey: tempKey,
246
+ // tusKey: null,
247
+ // add: true,
248
+ // base64: await this.getBase64(file),
249
+ // error: false,
250
+ // progress: 0
251
+ // });
252
+ //
253
+ // const upload = new tus.Upload(file, {
254
+ // endpoint: window.axios.defaults.baseURL + 'v1/' + this.field.extra_data.tus_endpoint,
255
+ // chunkSize: (2 * (1024 * 1024)), // 2 MB
256
+ // metadata: {
257
+ // filename: file.name,
258
+ // filetype: file.type,
259
+ // },
260
+ // onBeforeRequest(req) {
261
+ // let xhr = req.getUnderlyingObject()
262
+ // xhr.withCredentials = true;
263
+ // },
264
+ // onProgress(bytesUploaded, bytesTotal) {
265
+ // let percentage = (bytesUploaded / bytesTotal * 100).toFixed(0)
266
+ // vueInstance.tusImages = vueInstance.tusImages.map((image) => {
267
+ // if (image.tempKey === tempKey) {
268
+ // image.progress = percentage;
269
+ // }
270
+ // return image;
271
+ // });
272
+ // },
273
+ // onSuccess() {
274
+ // const urlParts = upload.url.split('/');
275
+ // const tusKey = urlParts[urlParts.length - 1];
276
+ // vueInstance.tusImages = vueInstance.tusImages.map((image) => {
277
+ // if (image.tempKey === tempKey) {
278
+ // image.tusKey = tusKey;
279
+ // }
280
+ // return image;
281
+ // });
282
+ // vueInstance.addImage(tusKey, true);
283
+ // },
284
+ // onError() {
285
+ // vueInstance.$modal.show('error', this.$i18n.get('generic.an_error_occurred'), this.$i18n.get('validation.image_upload_failed'));
286
+ // vueInstance.tusImages = vueInstance.tusImages.map((image) => {
287
+ // if (image.tempKey === tempKey) {
288
+ // image.error = true;
289
+ // }
290
+ // return image;
291
+ // });
292
+ // }
293
+ // });
294
+ //
295
+ // upload.start();
296
+ // this.tusUploads[tempKey] = upload;
297
+ },
298
+
299
+ getBase64(file: File): Promise<string> {
300
+ return new Promise((resolve, reject) => {
301
+ const reader = new FileReader();
302
+ reader.readAsDataURL(file);
303
+ reader.onload = () => resolve(reader.result as string);
304
+ reader.onerror = error => reject(error);
305
+ });
306
+ },
307
+
308
+ addImage(image: string, isTus = false) {
309
+ if (isTus) {
310
+ this.images.push({
311
+ tusKey: image,
312
+ add: true
313
+ });
314
+ } else {
315
+ this.images.push({
316
+ base64: image,
317
+ add: true
318
+ });
319
+ }
320
+ },
321
+
322
+ onRemoveImage(index, image, isTus = false) {
323
+ this.$modal.confirm(
324
+ undefined,
325
+ undefined,
326
+ undefined,
327
+ undefined,
328
+ () => {
329
+ this.removeImage(index, image, isTus);
330
+ }
331
+ );
332
+ },
333
+
334
+ removeImage(index: number, image: UploadImage, isTus = false) {
335
+ if (image.add === true) {
336
+ if (isTus) {
337
+ this.tusImages = this.tusImages.filter((img, idx) => idx !== index);
338
+ if (image.tempKey && this.tusUploads[image.tempKey]) {
339
+ this.tusUploads[image.tempKey].abort(true);
340
+ }
341
+ } else {
342
+ // Safe to just pop it from array since it's not been saved yet
343
+ this.images = this.images.filter((img, idx) => idx !== index );
344
+ }
345
+ (this.$refs.imageUpload as any).value = ''; // Need to clear the input field so the image can be selected again
346
+ } else {
347
+ // We need to flag the image for removal in API
348
+ image.remove = true;
349
+ this.images = this.images.map((existing, existingIndex) => {
350
+ return (existingIndex === index) ? image : existing;
351
+ });
352
+ }
353
+ },
354
+
355
+ validate(val) {
356
+ this.invalid = (this.field.required && (!val.length || !val.filter(image => !image.remove).length));
357
+ },
358
+
359
+ safeUrl(image) {
360
+ return (image.url) ? image.url : image.base64;
361
+ }
362
+ }
363
+ });
364
+ </script>
365
+
366
+ <style scoped lang="scss">
367
+ .added-images {
368
+ margin-top: var(--global-spacing);
369
+
370
+ .image {
371
+ display: inline-block;
372
+ width: 100px;
373
+ height: 100px;
374
+ overflow: hidden;
375
+ position: relative;
376
+ margin-right: var(--global-spacing);
377
+ cursor: pointer;
378
+ text-align: center;
379
+ vertical-align: middle;
380
+ user-select: none;
381
+ border-radius: var(--global-radius);
382
+
383
+ .remove-overlay {
384
+ display: none;
385
+ background-color: rgba(red, 0.8);
386
+ color: var(--color-light-txt);
387
+ position: absolute;
388
+ top: 0;
389
+ left: 0;
390
+ width: 100%;
391
+ height: 100%;
392
+ align-items: center;
393
+ justify-content: center;
394
+ }
395
+
396
+ .upload-error {
397
+ @extend .remove-overlay;
398
+ background-color: rgba(var(--color-danger-rgb), 0.3);
399
+ display: flex;
400
+ }
401
+
402
+ &:hover {
403
+ .remove-overlay {
404
+ display: flex;
405
+ }
406
+ }
407
+
408
+ .upload-progress {
409
+ position: absolute;
410
+ bottom: 0;
411
+ left: 0;
412
+ height: 15%;
413
+ width: 100%;
414
+ background-color: rgba(255,255,255, 0.5);
415
+
416
+ .progress-bar {
417
+ display: block;
418
+ height: 100%;
419
+ background-color: var(--color-success);
420
+ }
421
+ }
422
+
423
+ img {
424
+ object-fit: contain;
425
+ object-position: center;
426
+ max-width: 100%;
427
+ max-height: 100%;
428
+ }
429
+ }
430
+ }
431
+ </style>