@polymarbot/nuxt-layer-shadcn-ui 0.9.6 → 0.10.1

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,712 @@
1
+ <script setup lang="ts">
2
+ import type { UploadFile, UploadProps } from './types'
3
+
4
+ const props = withDefaults(defineProps<UploadProps>(), {
5
+ variant: 'button',
6
+ accept: undefined,
7
+ beforeUpload: undefined,
8
+ upload: undefined,
9
+ disabled: false,
10
+ readonly: false,
11
+ invalid: false,
12
+ fileList: undefined,
13
+ multiple: false,
14
+ maxCount: undefined,
15
+ maxSize: undefined,
16
+ text: undefined,
17
+ icon: undefined,
18
+ directory: false,
19
+ class: undefined,
20
+ })
21
+
22
+ const emit = defineEmits<{
23
+ 'update:fileList': [files: UploadFile[]]
24
+ 'change': [files: UploadFile[]]
25
+ 'remove': [file: UploadFile]
26
+ 'preview': [file: UploadFile]
27
+ 'error': [error: unknown]
28
+ }>()
29
+
30
+ const T = useTranslations('components.ui.Upload')
31
+ const isInvalid = useFormItemInvalid(() => props.invalid)
32
+
33
+ const inputRef = ref<HTMLInputElement | null>(null)
34
+ const isDragOver = ref(false)
35
+ const uploadingFiles = ref<UploadFile[]>([])
36
+ const imageLoadErrors = ref<Set<string | number>>(new Set())
37
+ const internalError = ref('')
38
+ const internalFileList = ref<UploadFile[]>([])
39
+
40
+ const previewVisible = ref(false)
41
+ const previewFileItem = ref<UploadFile | null>(null)
42
+
43
+ const objectUrls = new Set<string>()
44
+ function trackObjectUrl (url: string | undefined) {
45
+ if (url) objectUrls.add(url)
46
+ }
47
+ onBeforeUnmount(() => {
48
+ objectUrls.forEach(url => URL.revokeObjectURL(url))
49
+ objectUrls.clear()
50
+ })
51
+
52
+ function nextUid () {
53
+ return `upload-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
54
+ }
55
+
56
+ const isControlled = computed(() => props.fileList !== undefined)
57
+
58
+ const effectiveFileList = computed<UploadFile[]>(() =>
59
+ isControlled.value ? (props.fileList ?? []) : internalFileList.value,
60
+ )
61
+
62
+ const displayList = computed<UploadFile[]>(() => [
63
+ ...effectiveFileList.value,
64
+ ...uploadingFiles.value,
65
+ ])
66
+
67
+ const reachedMax = computed(() =>
68
+ !!props.maxCount && displayList.value.length >= props.maxCount,
69
+ )
70
+
71
+ const canPickMore = computed(() =>
72
+ !props.disabled && !props.readonly && !reachedMax.value,
73
+ )
74
+
75
+ const canRemove = computed(() => !props.disabled && !props.readonly)
76
+
77
+ const showTrigger = computed(() => !props.readonly)
78
+
79
+ const emptyStateClass = 'py-2 text-xs text-muted-foreground'
80
+
81
+ const overlayIconButtonClass = `
82
+ text-white/80
83
+ hover:text-white
84
+ cursor-pointer transition
85
+ hover:scale-110
86
+ `
87
+
88
+ function formatBytes (bytes: number) {
89
+ if (bytes < 1024) return `${bytes}B`
90
+ if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)}KB`
91
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)}MB`
92
+ return `${(bytes / 1024 / 1024 / 1024).toFixed(1)}GB`
93
+ }
94
+
95
+ const maxSizeLabel = computed(() => props.maxSize ? formatBytes(props.maxSize) : '')
96
+
97
+ const acceptLabel = computed(() => {
98
+ if (!props.accept) return ''
99
+ return props.accept
100
+ .split(',')
101
+ .map(s => s.trim())
102
+ .filter(Boolean)
103
+ .map(s => {
104
+ if (s === 'image/*') return T('hint.acceptImage')
105
+ if (s === 'video/*') return T('hint.acceptVideo')
106
+ if (s === 'audio/*') return T('hint.acceptAudio')
107
+ if (s.startsWith('.')) return s.slice(1).toUpperCase()
108
+ if (s.endsWith('/*')) return s
109
+ if (s.includes('/')) return s.split('/')[1]!.toUpperCase()
110
+ return s
111
+ })
112
+ .join(', ')
113
+ })
114
+
115
+ const allowMany = computed(() => props.multiple || props.directory)
116
+
117
+ const hintLines = computed(() => {
118
+ const lines: string[] = []
119
+ if (!allowMany.value) lines.push(T('hint.single'))
120
+ else if (props.maxCount) lines.push(T('hint.max', { max: props.maxCount }))
121
+ else lines.push(T('hint.multiple'))
122
+ if (acceptLabel.value) lines.push(T('hint.accept', { types: acceptLabel.value }))
123
+ if (maxSizeLabel.value) lines.push(T('hint.maxSize', { size: maxSizeLabel.value }))
124
+ return lines
125
+ })
126
+
127
+ defineSlots<{
128
+ hint?: (props: { lines: string[] }) => unknown
129
+ }>()
130
+
131
+ const triggerLabel = computed(() => {
132
+ if (props.text) return props.text
133
+ if (props.variant === 'drag') {
134
+ if (props.directory) return T('drag.titleDirectory')
135
+ return allowMany.value ? T('drag.titleMultiple') : T('drag.title')
136
+ }
137
+ return props.directory ? T('uploadDirectory') : T('upload')
138
+ })
139
+
140
+ const iconName = computed(() => {
141
+ if (props.icon) return props.icon
142
+ if (props.variant === 'box') return 'plus'
143
+ if (props.variant === 'drag') return 'inbox'
144
+ return 'upload'
145
+ })
146
+
147
+ const rootClass = computed(() => cn('w-full', props.class))
148
+
149
+ const dragAreaBase = `
150
+ group/upload
151
+ flex cursor-pointer flex-col items-center justify-center gap-2
152
+ rounded-md border border-dashed bg-muted/40 px-6 py-10
153
+ text-center transition-colors
154
+ dark:bg-muted/20
155
+ `
156
+
157
+ const boxTriggerBase = `
158
+ flex size-24 shrink-0 cursor-pointer flex-col items-center justify-center
159
+ gap-1 rounded-md border border-dashed bg-muted/40 text-xs
160
+ text-muted-foreground transition-colors
161
+ dark:bg-muted/20
162
+ `
163
+
164
+ const disabledClass = 'pointer-events-none cursor-not-allowed opacity-60'
165
+
166
+ function makeTriggerClass (base: string, idleHover: string) {
167
+ return cn(
168
+ base,
169
+ isInvalid.value ? 'border-danger' : idleHover,
170
+ isDragOver.value && !isInvalid.value && 'border-primary bg-primary/5',
171
+ props.disabled && disabledClass,
172
+ )
173
+ }
174
+
175
+ const dragAreaClass = computed(() => makeTriggerClass(
176
+ dragAreaBase,
177
+ `
178
+ border-border
179
+ hover:border-primary
180
+ `,
181
+ ))
182
+
183
+ const boxTriggerClass = computed(() => makeTriggerClass(
184
+ boxTriggerBase,
185
+ `
186
+ border-border
187
+ hover:border-primary hover:text-primary
188
+ `,
189
+ ))
190
+
191
+ const thumbBase = `
192
+ group/thumb
193
+ relative size-24 shrink-0 overflow-hidden rounded-md border bg-muted/40
194
+ dark:bg-muted/20
195
+ `
196
+
197
+ const rowItemBase = `
198
+ group/row
199
+ flex items-center gap-2 rounded-sm px-1 py-1 text-sm
200
+ hover:bg-muted/60
201
+ `
202
+
203
+ function thumbClass (file: UploadFile) {
204
+ const isError = file.status === 'error' || isInvalid.value
205
+ return cn(thumbBase, isError ? 'border-danger' : 'border-border')
206
+ }
207
+
208
+ function rowItemClass (file: UploadFile) {
209
+ const isError = file.status === 'error' || isInvalid.value
210
+ return cn(rowItemBase, isError && 'text-danger')
211
+ }
212
+
213
+ function isImageFile (file: UploadFile) {
214
+ if (file.uid != null && imageLoadErrors.value.has(file.uid)) return false
215
+ if (file.raw && file.raw.type) return file.raw.type.startsWith('image/')
216
+ return !!file.url
217
+ }
218
+
219
+ function onImageError (file: UploadFile) {
220
+ if (file.uid != null) imageLoadErrors.value.add(file.uid)
221
+ }
222
+
223
+ // Native <input accept> only filters in the file picker; drag-drop bypasses it,
224
+ // so we mirror the same patterns in JS for both paths.
225
+ function matchesAccept (file: File, accept: string) {
226
+ const patterns = accept.split(',').map(s => s.trim()).filter(Boolean)
227
+ if (!patterns.length) return true
228
+ const name = file.name.toLowerCase()
229
+ const type = file.type.toLowerCase()
230
+ return patterns.some(raw => {
231
+ const p = raw.toLowerCase()
232
+ if (p.startsWith('.')) return name.endsWith(p)
233
+ if (p.endsWith('/*')) return type.startsWith(p.slice(0, -1))
234
+ return type === p
235
+ })
236
+ }
237
+
238
+ function reportError (err: Error) {
239
+ internalError.value = err.message
240
+ emit('error', err)
241
+ }
242
+
243
+ async function processFiles (files: File[]) {
244
+ if (!files.length) return
245
+ internalError.value = ''
246
+
247
+ let candidates = files
248
+ if (!allowMany.value && candidates.length > 1) candidates = [ candidates[0]! ]
249
+
250
+ if (props.accept) {
251
+ const accept = props.accept
252
+ const rejected = candidates.filter(f => !matchesAccept(f, accept))
253
+ if (rejected.length) {
254
+ const names = rejected.map(f => f.name).join(', ')
255
+ reportError(new Error(T('error.accept', { files: names, accept: acceptLabel.value })))
256
+ candidates = candidates.filter(f => matchesAccept(f, accept))
257
+ }
258
+ }
259
+
260
+ if (props.maxCount) {
261
+ const remaining = props.maxCount - displayList.value.length
262
+ if (remaining <= 0) return
263
+ candidates = candidates.slice(0, remaining)
264
+ }
265
+
266
+ if (props.maxSize) {
267
+ const limit = props.maxSize
268
+ const oversize = candidates.filter(f => f.size > limit)
269
+ if (oversize.length) {
270
+ const names = oversize.map(f => f.name).join(', ')
271
+ reportError(new Error(T('error.oversize', { files: names, size: formatBytes(limit) })))
272
+ candidates = candidates.filter(f => f.size <= limit)
273
+ }
274
+ }
275
+
276
+ const accepted: (File | Blob)[] = []
277
+ for (const file of candidates) {
278
+ if (!props.beforeUpload) {
279
+ accepted.push(file)
280
+ continue
281
+ }
282
+ try {
283
+ const result = await props.beforeUpload(file)
284
+ if (result === false) continue
285
+ if (result instanceof File || result instanceof Blob) {
286
+ accepted.push(result)
287
+ } else {
288
+ accepted.push(file)
289
+ }
290
+ } catch {
291
+ // beforeUpload rejected — skip this file
292
+ }
293
+ }
294
+
295
+ if (!accepted.length) return
296
+
297
+ const entries: UploadFile[] = accepted.map((raw, i) => {
298
+ const isFile = raw instanceof File
299
+ const name = isFile ? raw.name : (candidates[i]?.name ?? 'file')
300
+ const url = raw.type.startsWith('image/') ? URL.createObjectURL(raw) : undefined
301
+ trackObjectUrl(url)
302
+ return {
303
+ uid: nextUid(),
304
+ name,
305
+ url,
306
+ status: 'uploading',
307
+ raw,
308
+ }
309
+ })
310
+
311
+ uploadingFiles.value.push(...entries)
312
+
313
+ if (!props.upload) {
314
+ finishUpload(entries, true)
315
+ return
316
+ }
317
+
318
+ try {
319
+ await props.upload(accepted)
320
+ finishUpload(entries, true)
321
+ } catch (err) {
322
+ finishUpload(entries, false)
323
+ reportError(err instanceof Error ? err : new Error(String(err)))
324
+ }
325
+ }
326
+
327
+ function finishUpload (entries: UploadFile[], ok: boolean) {
328
+ const entryUids = new Set(entries.map(e => e.uid))
329
+ uploadingFiles.value = uploadingFiles.value.filter(e => !entryUids.has(e.uid))
330
+
331
+ if (!ok) {
332
+ uploadingFiles.value.push(...entries.map(e => ({ ...e, status: 'error' as const })))
333
+ return
334
+ }
335
+
336
+ const done = entries.map(e => ({ ...e, status: 'done' as const }))
337
+ const next = [ ...effectiveFileList.value, ...done ]
338
+ if (!isControlled.value) internalFileList.value = next
339
+ emit('update:fileList', next)
340
+ emit('change', next)
341
+ }
342
+
343
+ function onInputChange (e: Event) {
344
+ const target = e.target as HTMLInputElement
345
+ if (target.files?.length) processFiles(Array.from(target.files))
346
+ target.value = ''
347
+ }
348
+
349
+ function triggerSelect () {
350
+ if (!canPickMore.value) return
351
+ inputRef.value?.click()
352
+ }
353
+
354
+ function removeFile (file: UploadFile) {
355
+ const inUploading = uploadingFiles.value.some(f => f.uid === file.uid)
356
+ if (inUploading) {
357
+ uploadingFiles.value = uploadingFiles.value.filter(f => f.uid !== file.uid)
358
+ } else {
359
+ const next = effectiveFileList.value.filter(f => f.uid !== file.uid)
360
+ if (!isControlled.value) internalFileList.value = next
361
+ emit('update:fileList', next)
362
+ emit('change', next)
363
+ }
364
+ emit('remove', file)
365
+ }
366
+
367
+ function previewFile (file: UploadFile) {
368
+ emit('preview', file)
369
+ if (isImageFile(file) && file.url) {
370
+ previewFileItem.value = file
371
+ previewVisible.value = true
372
+ }
373
+ }
374
+
375
+ function onDragOver (e: DragEvent) {
376
+ e.preventDefault()
377
+ if (canPickMore.value) isDragOver.value = true
378
+ }
379
+ function onDragLeave (e: DragEvent) {
380
+ e.preventDefault()
381
+ isDragOver.value = false
382
+ }
383
+
384
+ // Recursively read a FileSystemEntry into a flat File[] so dropped directories
385
+ // expand to their contents. readEntries returns at most ~100 children per call
386
+ // and must be looped until it yields an empty batch.
387
+ async function readEntry (entry: FileSystemEntry): Promise<File[]> {
388
+ if (entry.isFile) {
389
+ return new Promise(resolve => {
390
+ (entry as FileSystemFileEntry).file(f => resolve([ f ]), () => resolve([]))
391
+ })
392
+ }
393
+ if (entry.isDirectory) {
394
+ const reader = (entry as FileSystemDirectoryEntry).createReader()
395
+ const children: FileSystemEntry[] = []
396
+ while (true) {
397
+ const batch = await new Promise<FileSystemEntry[]>(resolve => {
398
+ reader.readEntries(entries => resolve(entries), () => resolve([]))
399
+ })
400
+ if (!batch.length) break
401
+ children.push(...batch)
402
+ }
403
+ const nested = await Promise.all(children.map(readEntry))
404
+ return nested.flat()
405
+ }
406
+ return []
407
+ }
408
+
409
+ async function onDrop (e: DragEvent) {
410
+ e.preventDefault()
411
+ isDragOver.value = false
412
+ if (!canPickMore.value) return
413
+
414
+ const items = e.dataTransfer?.items
415
+ if (items?.length && typeof items[0]?.webkitGetAsEntry === 'function') {
416
+ const entries = Array.from(items)
417
+ .map(item => item.webkitGetAsEntry())
418
+ .filter((entry): entry is FileSystemEntry => !!entry)
419
+ const nested = await Promise.all(entries.map(readEntry))
420
+ const files = nested.flat()
421
+ if (files.length) processFiles(files)
422
+ return
423
+ }
424
+
425
+ const files = e.dataTransfer?.files
426
+ if (files?.length) processFiles(Array.from(files))
427
+ }
428
+ </script>
429
+
430
+ <template>
431
+ <div :class="rootClass">
432
+ <!-- Hidden native input -->
433
+ <input
434
+ ref="inputRef"
435
+ type="file"
436
+ class="hidden"
437
+ :accept="accept"
438
+ :multiple="multiple"
439
+ :disabled="disabled"
440
+ :webkitdirectory="directory || undefined"
441
+ @change="onInputChange"
442
+ >
443
+
444
+ <!-- button variant: button trigger -->
445
+ <div
446
+ v-if="variant === 'button' && showTrigger"
447
+ class="gap-3 flex flex-wrap items-center"
448
+ >
449
+ <Button
450
+ variant="outline"
451
+ :disabled="disabled || reachedMax"
452
+ @click="triggerSelect"
453
+ >
454
+ <Icon :name="iconName" />
455
+ {{ triggerLabel }}
456
+ </Button>
457
+ <div class="text-xs text-muted-foreground py-1.5">
458
+ <slot
459
+ name="hint"
460
+ :lines="hintLines"
461
+ >
462
+ <div
463
+ v-for="line in hintLines"
464
+ :key="line"
465
+ >
466
+ {{ line }}
467
+ </div>
468
+ </slot>
469
+ </div>
470
+ </div>
471
+
472
+ <!-- drag variant: dashed drop area trigger -->
473
+ <div
474
+ v-else-if="variant === 'drag' && showTrigger"
475
+ :class="dragAreaClass"
476
+ role="button"
477
+ :aria-disabled="disabled || undefined"
478
+ @click="triggerSelect"
479
+ @dragover="onDragOver"
480
+ @dragleave="onDragLeave"
481
+ @drop="onDrop"
482
+ >
483
+ <Icon
484
+ :name="iconName"
485
+ class="size-10 text-primary"
486
+ :class="isInvalid && 'text-danger'"
487
+ />
488
+ <div class="text-foreground font-medium">
489
+ {{ triggerLabel }}
490
+ </div>
491
+ <div class="text-xs text-muted-foreground">
492
+ <slot
493
+ name="hint"
494
+ :lines="hintLines"
495
+ >
496
+ <div
497
+ v-for="line in hintLines"
498
+ :key="line"
499
+ >
500
+ {{ line }}
501
+ </div>
502
+ </slot>
503
+ </div>
504
+ </div>
505
+
506
+ <!-- Shared row list (button + drag) -->
507
+ <template v-if="variant !== 'box'">
508
+ <ul
509
+ v-if="displayList.length"
510
+ :class="[ showTrigger && 'mt-2', 'flex flex-col' ]"
511
+ >
512
+ <li
513
+ v-for="file in displayList"
514
+ :key="file.uid"
515
+ :class="rowItemClass(file)"
516
+ >
517
+ <Icon
518
+ name="paperclip"
519
+ class="size-4 shrink-0"
520
+ />
521
+ <span class="min-w-0 flex-1 truncate">
522
+ {{ file.name }}
523
+ </span>
524
+ <Icon
525
+ v-if="file.status === 'uploading'"
526
+ name="loader-circle"
527
+ class="size-4 animate-spin text-muted-foreground shrink-0"
528
+ />
529
+ <button
530
+ v-if="canRemove"
531
+ type="button"
532
+ class="
533
+ text-muted-foreground
534
+ hover:text-foreground
535
+ shrink-0 cursor-pointer opacity-0 transition
536
+ group-hover/row:opacity-100
537
+ "
538
+ :aria-label="T('remove')"
539
+ @click="removeFile(file)"
540
+ >
541
+ <Icon name="trash-2" />
542
+ </button>
543
+ </li>
544
+ </ul>
545
+ <div
546
+ v-else-if="!showTrigger"
547
+ :class="emptyStateClass"
548
+ >
549
+ {{ T('empty') }}
550
+ </div>
551
+ </template>
552
+
553
+ <!-- box variant: grid of thumbs + add-more box -->
554
+ <template v-else>
555
+ <div
556
+ v-if="displayList.length || showTrigger"
557
+ class="gap-3 flex flex-wrap items-start"
558
+ >
559
+ <div
560
+ v-for="file in displayList"
561
+ :key="file.uid"
562
+ :class="thumbClass(file)"
563
+ >
564
+ <img
565
+ v-if="isImageFile(file) && file.url"
566
+ :src="file.url"
567
+ :alt="file.name"
568
+ class="size-full object-cover"
569
+ @error="onImageError(file)"
570
+ >
571
+ <div
572
+ v-else
573
+ class="
574
+ gap-1 p-2 flex size-full flex-col items-center justify-center
575
+ "
576
+ >
577
+ <Icon
578
+ name="image"
579
+ class="size-6 text-muted-foreground"
580
+ :class="file.status === 'error' && 'text-danger'"
581
+ />
582
+ <span
583
+ class="px-1 leading-tight line-clamp-2 text-center text-[10px]"
584
+ >
585
+ {{ file.name }}
586
+ </span>
587
+ </div>
588
+
589
+ <!-- Uploading overlay -->
590
+ <div
591
+ v-if="file.status === 'uploading'"
592
+ class="
593
+ inset-0 gap-2 bg-background/80 px-3 text-xs absolute flex flex-col
594
+ items-center justify-center
595
+ "
596
+ >
597
+ <span>{{ T('uploading') }}</span>
598
+ <div class="h-1 bg-muted w-full overflow-hidden rounded-full">
599
+ <div class="upload-progress bg-primary h-full" />
600
+ </div>
601
+ </div>
602
+
603
+ <!-- Hover overlay -->
604
+ <div
605
+ v-else-if="isImageFile(file) || canRemove"
606
+ class="
607
+ inset-0 gap-3 bg-black/50 absolute flex items-center
608
+ justify-center opacity-0 transition
609
+ group-hover/thumb:opacity-100
610
+ "
611
+ >
612
+ <button
613
+ v-if="isImageFile(file) && file.url"
614
+ type="button"
615
+ :class="overlayIconButtonClass"
616
+ :aria-label="T('preview')"
617
+ @click="previewFile(file)"
618
+ >
619
+ <Icon name="eye" />
620
+ </button>
621
+ <button
622
+ v-if="canRemove"
623
+ type="button"
624
+ :class="overlayIconButtonClass"
625
+ :aria-label="T('remove')"
626
+ @click="removeFile(file)"
627
+ >
628
+ <Icon name="trash-2" />
629
+ </button>
630
+ </div>
631
+ </div>
632
+
633
+ <button
634
+ v-if="showTrigger && !reachedMax"
635
+ type="button"
636
+ :class="boxTriggerClass"
637
+ :disabled="disabled"
638
+ @click="triggerSelect"
639
+ @dragover="onDragOver"
640
+ @dragleave="onDragLeave"
641
+ @drop="onDrop"
642
+ >
643
+ <Icon
644
+ :name="iconName"
645
+ class="size-5"
646
+ />
647
+ <span>{{ triggerLabel }}</span>
648
+ </button>
649
+ </div>
650
+
651
+ <div
652
+ v-else
653
+ :class="emptyStateClass"
654
+ >
655
+ {{ T('empty') }}
656
+ </div>
657
+
658
+ <div
659
+ v-if="showTrigger"
660
+ class="mt-2 text-xs text-muted-foreground"
661
+ >
662
+ <slot
663
+ name="hint"
664
+ :lines="hintLines"
665
+ >
666
+ <div
667
+ v-for="line in hintLines"
668
+ :key="line"
669
+ >
670
+ {{ line }}
671
+ </div>
672
+ </slot>
673
+ </div>
674
+ </template>
675
+
676
+ <p
677
+ v-if="internalError"
678
+ class="mt-2 text-sm text-danger"
679
+ >
680
+ {{ internalError }}
681
+ </p>
682
+
683
+ <!-- Image preview modal (box variant only) -->
684
+ <Modal
685
+ v-if="variant === 'box'"
686
+ v-model:visible="previewVisible"
687
+ hideHeader
688
+ hideFooter
689
+ :title="previewFileItem?.name"
690
+ class="max-w-3xl"
691
+ >
692
+ <img
693
+ v-if="previewFileItem?.url"
694
+ :src="previewFileItem.url"
695
+ :alt="previewFileItem.name"
696
+ class="mx-auto max-h-[70vh] max-w-full object-contain"
697
+ >
698
+ </Modal>
699
+ </div>
700
+ </template>
701
+
702
+ <style scoped>
703
+ @keyframes upload-progress-anim {
704
+ 0% { transform: translateX(-100%); }
705
+ 100% { transform: translateX(100%); }
706
+ }
707
+
708
+ .upload-progress {
709
+ width: 60%;
710
+ animation: upload-progress-anim 1.2s ease-in-out infinite;
711
+ }
712
+ </style>