@polymarbot/nuxt-layer-shadcn-ui 0.9.6 → 0.10.0
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.
- package/app/components/ui/AdminLayout/types.ts +3 -2
- package/app/components/ui/Alert/index.vue +1 -5
- package/app/components/ui/Breadcrumb/types.ts +3 -1
- package/app/components/ui/Button/types.ts +2 -1
- package/app/components/ui/Dropdown/ItemIcon.vue +1 -7
- package/app/components/ui/Icon/index.stories.ts +25 -0
- package/app/components/ui/Icon/index.vue +1 -0
- package/app/components/ui/Icon/types.ts +4 -2
- package/app/components/ui/ModalContent/types.ts +2 -2
- package/app/components/ui/Tabs/index.vue +2 -6
- package/app/components/ui/Upload/en.json +21 -0
- package/app/components/ui/Upload/index.stories.ts +381 -0
- package/app/components/ui/Upload/index.vue +598 -0
- package/app/components/ui/Upload/types.ts +36 -0
- package/i18n/messages/ar.json +21 -0
- package/i18n/messages/de.json +21 -0
- package/i18n/messages/en.json +21 -0
- package/i18n/messages/es.json +21 -0
- package/i18n/messages/fr.json +21 -0
- package/i18n/messages/hi.json +21 -0
- package/i18n/messages/id.json +21 -0
- package/i18n/messages/it.json +21 -0
- package/i18n/messages/ja.json +21 -0
- package/i18n/messages/ko.json +21 -0
- package/i18n/messages/nl.json +21 -0
- package/i18n/messages/pl.json +21 -0
- package/i18n/messages/pt.json +21 -0
- package/i18n/messages/ru.json +21 -0
- package/i18n/messages/th.json +21 -0
- package/i18n/messages/tr.json +21 -0
- package/i18n/messages/vi.json +21 -0
- package/i18n/messages/zh-CN.json +21 -0
- package/i18n/messages/zh-TW.json +21 -0
- package/package.json +2 -2
|
@@ -0,0 +1,598 @@
|
|
|
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 internalFileList = ref<UploadFile[]>([])
|
|
38
|
+
|
|
39
|
+
const previewVisible = ref(false)
|
|
40
|
+
const previewFileItem = ref<UploadFile | null>(null)
|
|
41
|
+
|
|
42
|
+
const objectUrls = new Set<string>()
|
|
43
|
+
function trackObjectUrl (url: string | undefined) {
|
|
44
|
+
if (url) objectUrls.add(url)
|
|
45
|
+
}
|
|
46
|
+
onBeforeUnmount(() => {
|
|
47
|
+
objectUrls.forEach(url => URL.revokeObjectURL(url))
|
|
48
|
+
objectUrls.clear()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
function nextUid () {
|
|
52
|
+
return `upload-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const isControlled = computed(() => props.fileList !== undefined)
|
|
56
|
+
|
|
57
|
+
const effectiveFileList = computed<UploadFile[]>(() =>
|
|
58
|
+
isControlled.value ? (props.fileList ?? []) : internalFileList.value,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
const displayList = computed<UploadFile[]>(() => [
|
|
62
|
+
...effectiveFileList.value,
|
|
63
|
+
...uploadingFiles.value,
|
|
64
|
+
])
|
|
65
|
+
|
|
66
|
+
const reachedMax = computed(() =>
|
|
67
|
+
!!props.maxCount && displayList.value.length >= props.maxCount,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
const canPickMore = computed(() =>
|
|
71
|
+
!props.disabled && !props.readonly && !reachedMax.value,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
const canRemove = computed(() => !props.disabled && !props.readonly)
|
|
75
|
+
|
|
76
|
+
const showTrigger = computed(() => !props.readonly)
|
|
77
|
+
|
|
78
|
+
const emptyStateClass = 'py-2 text-xs text-muted-foreground'
|
|
79
|
+
|
|
80
|
+
const overlayIconButtonClass = `
|
|
81
|
+
text-white/80
|
|
82
|
+
hover:text-white
|
|
83
|
+
cursor-pointer transition
|
|
84
|
+
hover:scale-110
|
|
85
|
+
`
|
|
86
|
+
|
|
87
|
+
function formatBytes (bytes: number) {
|
|
88
|
+
if (bytes < 1024) return `${bytes}B`
|
|
89
|
+
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)}KB`
|
|
90
|
+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)}MB`
|
|
91
|
+
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)}GB`
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const maxSizeLabel = computed(() => props.maxSize ? formatBytes(props.maxSize) : '')
|
|
95
|
+
|
|
96
|
+
const allowMany = computed(() => props.multiple || props.directory)
|
|
97
|
+
|
|
98
|
+
const hintLines = computed(() => {
|
|
99
|
+
const lines: string[] = []
|
|
100
|
+
if (!allowMany.value) lines.push(T('hint.single'))
|
|
101
|
+
else if (props.maxCount) lines.push(T('hint.max', { max: props.maxCount }))
|
|
102
|
+
else lines.push(T('hint.multiple'))
|
|
103
|
+
if (maxSizeLabel.value) lines.push(T('hint.maxSize', { size: maxSizeLabel.value }))
|
|
104
|
+
return lines
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
const dragTitleText = computed(() => {
|
|
108
|
+
if (props.text) return props.text
|
|
109
|
+
return allowMany.value ? T('drag.titleMultiple') : T('drag.title')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
const triggerLabel = computed(() => {
|
|
113
|
+
if (props.text) return props.text
|
|
114
|
+
return props.directory ? T('uploadDirectory') : T('upload')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
const iconName = computed(() => {
|
|
118
|
+
if (props.icon) return props.icon
|
|
119
|
+
if (props.variant === 'box') return 'plus'
|
|
120
|
+
if (props.variant === 'drag') return 'inbox'
|
|
121
|
+
return 'upload'
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
const rootClass = computed(() => cn('w-full', props.class))
|
|
125
|
+
|
|
126
|
+
const dragAreaBase = `
|
|
127
|
+
group/upload
|
|
128
|
+
flex cursor-pointer flex-col items-center justify-center gap-2
|
|
129
|
+
rounded-md border border-dashed bg-muted/40 px-6 py-10
|
|
130
|
+
text-center transition-colors
|
|
131
|
+
dark:bg-muted/20
|
|
132
|
+
`
|
|
133
|
+
|
|
134
|
+
const boxTriggerBase = `
|
|
135
|
+
flex size-24 shrink-0 cursor-pointer flex-col items-center justify-center
|
|
136
|
+
gap-1 rounded-md border border-dashed bg-muted/40 text-xs
|
|
137
|
+
text-muted-foreground transition-colors
|
|
138
|
+
dark:bg-muted/20
|
|
139
|
+
`
|
|
140
|
+
|
|
141
|
+
const disabledClass = 'pointer-events-none cursor-not-allowed opacity-60'
|
|
142
|
+
|
|
143
|
+
function makeTriggerClass (base: string, idleHover: string) {
|
|
144
|
+
return cn(
|
|
145
|
+
base,
|
|
146
|
+
isInvalid.value ? 'border-danger' : idleHover,
|
|
147
|
+
isDragOver.value && !isInvalid.value && 'border-primary bg-primary/5',
|
|
148
|
+
props.disabled && disabledClass,
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const dragAreaClass = computed(() => makeTriggerClass(
|
|
153
|
+
dragAreaBase,
|
|
154
|
+
`
|
|
155
|
+
border-border
|
|
156
|
+
hover:border-primary
|
|
157
|
+
`,
|
|
158
|
+
))
|
|
159
|
+
|
|
160
|
+
const boxTriggerClass = computed(() => makeTriggerClass(
|
|
161
|
+
boxTriggerBase,
|
|
162
|
+
`
|
|
163
|
+
border-border
|
|
164
|
+
hover:border-primary hover:text-primary
|
|
165
|
+
`,
|
|
166
|
+
))
|
|
167
|
+
|
|
168
|
+
const thumbBase = `
|
|
169
|
+
group/thumb
|
|
170
|
+
relative size-24 shrink-0 overflow-hidden rounded-md border bg-muted/40
|
|
171
|
+
dark:bg-muted/20
|
|
172
|
+
`
|
|
173
|
+
|
|
174
|
+
const rowItemBase = `
|
|
175
|
+
group/row
|
|
176
|
+
flex items-center gap-2 rounded-sm px-1 py-1 text-sm
|
|
177
|
+
hover:bg-muted/60
|
|
178
|
+
`
|
|
179
|
+
|
|
180
|
+
function thumbClass (file: UploadFile) {
|
|
181
|
+
const isError = file.status === 'error' || isInvalid.value
|
|
182
|
+
return cn(thumbBase, isError ? 'border-danger' : 'border-border')
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function rowItemClass (file: UploadFile) {
|
|
186
|
+
const isError = file.status === 'error' || isInvalid.value
|
|
187
|
+
return cn(rowItemBase, isError && 'text-danger')
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function isImageFile (file: UploadFile) {
|
|
191
|
+
if (file.uid != null && imageLoadErrors.value.has(file.uid)) return false
|
|
192
|
+
if (file.raw && file.raw.type) return file.raw.type.startsWith('image/')
|
|
193
|
+
return !!file.url
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function onImageError (file: UploadFile) {
|
|
197
|
+
if (file.uid != null) imageLoadErrors.value.add(file.uid)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function processFiles (files: File[]) {
|
|
201
|
+
if (!files.length) return
|
|
202
|
+
|
|
203
|
+
let candidates = files
|
|
204
|
+
if (!allowMany.value && candidates.length > 1) candidates = [ candidates[0]! ]
|
|
205
|
+
|
|
206
|
+
if (props.maxCount) {
|
|
207
|
+
const remaining = props.maxCount - displayList.value.length
|
|
208
|
+
if (remaining <= 0) return
|
|
209
|
+
candidates = candidates.slice(0, remaining)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (props.maxSize) {
|
|
213
|
+
const limit = props.maxSize
|
|
214
|
+
const oversize = candidates.filter(f => f.size > limit)
|
|
215
|
+
if (oversize.length) {
|
|
216
|
+
const names = oversize.map(f => f.name).join(', ')
|
|
217
|
+
emit('error', new Error(T('error.oversize', { files: names, size: formatBytes(limit) })))
|
|
218
|
+
candidates = candidates.filter(f => f.size <= limit)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const accepted: (File | Blob)[] = []
|
|
223
|
+
for (const file of candidates) {
|
|
224
|
+
if (!props.beforeUpload) {
|
|
225
|
+
accepted.push(file)
|
|
226
|
+
continue
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
const result = await props.beforeUpload(file)
|
|
230
|
+
if (result === false) continue
|
|
231
|
+
if (result instanceof File || result instanceof Blob) {
|
|
232
|
+
accepted.push(result)
|
|
233
|
+
} else {
|
|
234
|
+
accepted.push(file)
|
|
235
|
+
}
|
|
236
|
+
} catch {
|
|
237
|
+
// beforeUpload rejected — skip this file
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (!accepted.length) return
|
|
242
|
+
|
|
243
|
+
const entries: UploadFile[] = accepted.map((raw, i) => {
|
|
244
|
+
const isFile = raw instanceof File
|
|
245
|
+
const name = isFile ? raw.name : (candidates[i]?.name ?? 'file')
|
|
246
|
+
const url = raw.type.startsWith('image/') ? URL.createObjectURL(raw) : undefined
|
|
247
|
+
trackObjectUrl(url)
|
|
248
|
+
return {
|
|
249
|
+
uid: nextUid(),
|
|
250
|
+
name,
|
|
251
|
+
url,
|
|
252
|
+
status: 'uploading',
|
|
253
|
+
raw,
|
|
254
|
+
}
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
uploadingFiles.value.push(...entries)
|
|
258
|
+
|
|
259
|
+
if (!props.upload) {
|
|
260
|
+
finishUpload(entries, true)
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
await props.upload(accepted)
|
|
266
|
+
finishUpload(entries, true)
|
|
267
|
+
} catch (err) {
|
|
268
|
+
finishUpload(entries, false)
|
|
269
|
+
emit('error', err)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function finishUpload (entries: UploadFile[], ok: boolean) {
|
|
274
|
+
const entryUids = new Set(entries.map(e => e.uid))
|
|
275
|
+
uploadingFiles.value = uploadingFiles.value.filter(e => !entryUids.has(e.uid))
|
|
276
|
+
|
|
277
|
+
if (!ok) {
|
|
278
|
+
uploadingFiles.value.push(...entries.map(e => ({ ...e, status: 'error' as const })))
|
|
279
|
+
return
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const done = entries.map(e => ({ ...e, status: 'done' as const }))
|
|
283
|
+
const next = [ ...effectiveFileList.value, ...done ]
|
|
284
|
+
if (!isControlled.value) internalFileList.value = next
|
|
285
|
+
emit('update:fileList', next)
|
|
286
|
+
emit('change', next)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function onInputChange (e: Event) {
|
|
290
|
+
const target = e.target as HTMLInputElement
|
|
291
|
+
if (target.files?.length) processFiles(Array.from(target.files))
|
|
292
|
+
target.value = ''
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function triggerSelect () {
|
|
296
|
+
if (!canPickMore.value) return
|
|
297
|
+
inputRef.value?.click()
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function removeFile (file: UploadFile) {
|
|
301
|
+
const inUploading = uploadingFiles.value.some(f => f.uid === file.uid)
|
|
302
|
+
if (inUploading) {
|
|
303
|
+
uploadingFiles.value = uploadingFiles.value.filter(f => f.uid !== file.uid)
|
|
304
|
+
} else {
|
|
305
|
+
const next = effectiveFileList.value.filter(f => f.uid !== file.uid)
|
|
306
|
+
if (!isControlled.value) internalFileList.value = next
|
|
307
|
+
emit('update:fileList', next)
|
|
308
|
+
emit('change', next)
|
|
309
|
+
}
|
|
310
|
+
emit('remove', file)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function previewFile (file: UploadFile) {
|
|
314
|
+
emit('preview', file)
|
|
315
|
+
if (isImageFile(file) && file.url) {
|
|
316
|
+
previewFileItem.value = file
|
|
317
|
+
previewVisible.value = true
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function onDragOver (e: DragEvent) {
|
|
322
|
+
e.preventDefault()
|
|
323
|
+
if (canPickMore.value) isDragOver.value = true
|
|
324
|
+
}
|
|
325
|
+
function onDragLeave (e: DragEvent) {
|
|
326
|
+
e.preventDefault()
|
|
327
|
+
isDragOver.value = false
|
|
328
|
+
}
|
|
329
|
+
function onDrop (e: DragEvent) {
|
|
330
|
+
e.preventDefault()
|
|
331
|
+
isDragOver.value = false
|
|
332
|
+
if (!canPickMore.value) return
|
|
333
|
+
const files = e.dataTransfer?.files
|
|
334
|
+
if (files?.length) processFiles(Array.from(files))
|
|
335
|
+
}
|
|
336
|
+
</script>
|
|
337
|
+
|
|
338
|
+
<template>
|
|
339
|
+
<div :class="rootClass">
|
|
340
|
+
<!-- Hidden native input -->
|
|
341
|
+
<input
|
|
342
|
+
ref="inputRef"
|
|
343
|
+
type="file"
|
|
344
|
+
class="hidden"
|
|
345
|
+
:accept="accept"
|
|
346
|
+
:multiple="multiple"
|
|
347
|
+
:disabled="disabled"
|
|
348
|
+
:webkitdirectory="directory || undefined"
|
|
349
|
+
@change="onInputChange"
|
|
350
|
+
>
|
|
351
|
+
|
|
352
|
+
<!-- button variant: button trigger -->
|
|
353
|
+
<div
|
|
354
|
+
v-if="variant === 'button' && showTrigger"
|
|
355
|
+
class="gap-3 flex flex-wrap items-center"
|
|
356
|
+
>
|
|
357
|
+
<Button
|
|
358
|
+
variant="outline"
|
|
359
|
+
:disabled="disabled || reachedMax"
|
|
360
|
+
@click="triggerSelect"
|
|
361
|
+
>
|
|
362
|
+
<Icon :name="iconName" />
|
|
363
|
+
{{ triggerLabel }}
|
|
364
|
+
</Button>
|
|
365
|
+
<div class="text-xs text-muted-foreground py-1.5">
|
|
366
|
+
<div
|
|
367
|
+
v-for="line in hintLines"
|
|
368
|
+
:key="line"
|
|
369
|
+
>
|
|
370
|
+
{{ line }}
|
|
371
|
+
</div>
|
|
372
|
+
</div>
|
|
373
|
+
</div>
|
|
374
|
+
|
|
375
|
+
<!-- drag variant: dashed drop area trigger -->
|
|
376
|
+
<div
|
|
377
|
+
v-else-if="variant === 'drag' && showTrigger"
|
|
378
|
+
:class="dragAreaClass"
|
|
379
|
+
role="button"
|
|
380
|
+
:aria-disabled="disabled || undefined"
|
|
381
|
+
@click="triggerSelect"
|
|
382
|
+
@dragover="onDragOver"
|
|
383
|
+
@dragleave="onDragLeave"
|
|
384
|
+
@drop="onDrop"
|
|
385
|
+
>
|
|
386
|
+
<Icon
|
|
387
|
+
:name="iconName"
|
|
388
|
+
class="size-10 text-primary"
|
|
389
|
+
:class="isInvalid && 'text-danger'"
|
|
390
|
+
/>
|
|
391
|
+
<div class="text-foreground font-medium">
|
|
392
|
+
{{ dragTitleText }}
|
|
393
|
+
</div>
|
|
394
|
+
<div class="text-xs text-muted-foreground">
|
|
395
|
+
<div
|
|
396
|
+
v-for="line in hintLines"
|
|
397
|
+
:key="line"
|
|
398
|
+
>
|
|
399
|
+
{{ line }}
|
|
400
|
+
</div>
|
|
401
|
+
</div>
|
|
402
|
+
</div>
|
|
403
|
+
|
|
404
|
+
<!-- Shared row list (button + drag) -->
|
|
405
|
+
<template v-if="variant !== 'box'">
|
|
406
|
+
<ul
|
|
407
|
+
v-if="displayList.length"
|
|
408
|
+
:class="[ showTrigger && 'mt-2', 'flex flex-col' ]"
|
|
409
|
+
>
|
|
410
|
+
<li
|
|
411
|
+
v-for="file in displayList"
|
|
412
|
+
:key="file.uid"
|
|
413
|
+
:class="rowItemClass(file)"
|
|
414
|
+
>
|
|
415
|
+
<Icon
|
|
416
|
+
name="paperclip"
|
|
417
|
+
class="size-4 shrink-0"
|
|
418
|
+
/>
|
|
419
|
+
<span class="min-w-0 flex-1 truncate">
|
|
420
|
+
{{ file.name }}
|
|
421
|
+
</span>
|
|
422
|
+
<Icon
|
|
423
|
+
v-if="file.status === 'uploading'"
|
|
424
|
+
name="loader-circle"
|
|
425
|
+
class="size-4 animate-spin text-muted-foreground shrink-0"
|
|
426
|
+
/>
|
|
427
|
+
<button
|
|
428
|
+
v-if="canRemove"
|
|
429
|
+
type="button"
|
|
430
|
+
class="
|
|
431
|
+
text-muted-foreground
|
|
432
|
+
hover:text-foreground
|
|
433
|
+
shrink-0 cursor-pointer opacity-0 transition
|
|
434
|
+
group-hover/row:opacity-100
|
|
435
|
+
"
|
|
436
|
+
:aria-label="T('remove')"
|
|
437
|
+
@click="removeFile(file)"
|
|
438
|
+
>
|
|
439
|
+
<Icon name="trash-2" />
|
|
440
|
+
</button>
|
|
441
|
+
</li>
|
|
442
|
+
</ul>
|
|
443
|
+
<div
|
|
444
|
+
v-else-if="!showTrigger"
|
|
445
|
+
:class="emptyStateClass"
|
|
446
|
+
>
|
|
447
|
+
{{ T('empty') }}
|
|
448
|
+
</div>
|
|
449
|
+
</template>
|
|
450
|
+
|
|
451
|
+
<!-- box variant: grid of thumbs + add-more box -->
|
|
452
|
+
<template v-else>
|
|
453
|
+
<div
|
|
454
|
+
v-if="displayList.length || showTrigger"
|
|
455
|
+
class="gap-3 flex flex-wrap items-start"
|
|
456
|
+
>
|
|
457
|
+
<div
|
|
458
|
+
v-for="file in displayList"
|
|
459
|
+
:key="file.uid"
|
|
460
|
+
:class="thumbClass(file)"
|
|
461
|
+
>
|
|
462
|
+
<img
|
|
463
|
+
v-if="isImageFile(file) && file.url"
|
|
464
|
+
:src="file.url"
|
|
465
|
+
:alt="file.name"
|
|
466
|
+
class="size-full object-cover"
|
|
467
|
+
@error="onImageError(file)"
|
|
468
|
+
>
|
|
469
|
+
<div
|
|
470
|
+
v-else
|
|
471
|
+
class="
|
|
472
|
+
gap-1 p-2 flex size-full flex-col items-center justify-center
|
|
473
|
+
"
|
|
474
|
+
>
|
|
475
|
+
<Icon
|
|
476
|
+
name="image"
|
|
477
|
+
class="size-6 text-muted-foreground"
|
|
478
|
+
:class="file.status === 'error' && 'text-danger'"
|
|
479
|
+
/>
|
|
480
|
+
<span
|
|
481
|
+
class="px-1 leading-tight line-clamp-2 text-center text-[10px]"
|
|
482
|
+
>
|
|
483
|
+
{{ file.name }}
|
|
484
|
+
</span>
|
|
485
|
+
</div>
|
|
486
|
+
|
|
487
|
+
<!-- Uploading overlay -->
|
|
488
|
+
<div
|
|
489
|
+
v-if="file.status === 'uploading'"
|
|
490
|
+
class="
|
|
491
|
+
inset-0 gap-2 bg-background/80 px-3 text-xs absolute flex flex-col
|
|
492
|
+
items-center justify-center
|
|
493
|
+
"
|
|
494
|
+
>
|
|
495
|
+
<span>{{ T('uploading') }}</span>
|
|
496
|
+
<div class="h-1 bg-muted w-full overflow-hidden rounded-full">
|
|
497
|
+
<div class="upload-progress bg-primary h-full" />
|
|
498
|
+
</div>
|
|
499
|
+
</div>
|
|
500
|
+
|
|
501
|
+
<!-- Hover overlay -->
|
|
502
|
+
<div
|
|
503
|
+
v-else-if="isImageFile(file) || canRemove"
|
|
504
|
+
class="
|
|
505
|
+
inset-0 gap-3 bg-black/50 absolute flex items-center
|
|
506
|
+
justify-center opacity-0 transition
|
|
507
|
+
group-hover/thumb:opacity-100
|
|
508
|
+
"
|
|
509
|
+
>
|
|
510
|
+
<button
|
|
511
|
+
v-if="isImageFile(file) && file.url"
|
|
512
|
+
type="button"
|
|
513
|
+
:class="overlayIconButtonClass"
|
|
514
|
+
:aria-label="T('preview')"
|
|
515
|
+
@click="previewFile(file)"
|
|
516
|
+
>
|
|
517
|
+
<Icon name="eye" />
|
|
518
|
+
</button>
|
|
519
|
+
<button
|
|
520
|
+
v-if="canRemove"
|
|
521
|
+
type="button"
|
|
522
|
+
:class="overlayIconButtonClass"
|
|
523
|
+
:aria-label="T('remove')"
|
|
524
|
+
@click="removeFile(file)"
|
|
525
|
+
>
|
|
526
|
+
<Icon name="trash-2" />
|
|
527
|
+
</button>
|
|
528
|
+
</div>
|
|
529
|
+
</div>
|
|
530
|
+
|
|
531
|
+
<button
|
|
532
|
+
v-if="showTrigger && !reachedMax"
|
|
533
|
+
type="button"
|
|
534
|
+
:class="boxTriggerClass"
|
|
535
|
+
:disabled="disabled"
|
|
536
|
+
@click="triggerSelect"
|
|
537
|
+
@dragover="onDragOver"
|
|
538
|
+
@dragleave="onDragLeave"
|
|
539
|
+
@drop="onDrop"
|
|
540
|
+
>
|
|
541
|
+
<Icon
|
|
542
|
+
:name="iconName"
|
|
543
|
+
class="size-5"
|
|
544
|
+
/>
|
|
545
|
+
<span>{{ triggerLabel }}</span>
|
|
546
|
+
</button>
|
|
547
|
+
</div>
|
|
548
|
+
|
|
549
|
+
<div
|
|
550
|
+
v-else
|
|
551
|
+
:class="emptyStateClass"
|
|
552
|
+
>
|
|
553
|
+
{{ T('empty') }}
|
|
554
|
+
</div>
|
|
555
|
+
|
|
556
|
+
<div
|
|
557
|
+
v-if="showTrigger"
|
|
558
|
+
class="mt-2 text-xs text-muted-foreground"
|
|
559
|
+
>
|
|
560
|
+
<div
|
|
561
|
+
v-for="line in hintLines"
|
|
562
|
+
:key="line"
|
|
563
|
+
>
|
|
564
|
+
{{ line }}
|
|
565
|
+
</div>
|
|
566
|
+
</div>
|
|
567
|
+
</template>
|
|
568
|
+
|
|
569
|
+
<!-- Image preview modal (box variant only) -->
|
|
570
|
+
<Modal
|
|
571
|
+
v-if="variant === 'box'"
|
|
572
|
+
v-model:visible="previewVisible"
|
|
573
|
+
hideHeader
|
|
574
|
+
hideFooter
|
|
575
|
+
:title="previewFileItem?.name"
|
|
576
|
+
class="max-w-3xl"
|
|
577
|
+
>
|
|
578
|
+
<img
|
|
579
|
+
v-if="previewFileItem?.url"
|
|
580
|
+
:src="previewFileItem.url"
|
|
581
|
+
:alt="previewFileItem.name"
|
|
582
|
+
class="mx-auto max-h-[70vh] max-w-full object-contain"
|
|
583
|
+
>
|
|
584
|
+
</Modal>
|
|
585
|
+
</div>
|
|
586
|
+
</template>
|
|
587
|
+
|
|
588
|
+
<style scoped>
|
|
589
|
+
@keyframes upload-progress-anim {
|
|
590
|
+
0% { transform: translateX(-100%); }
|
|
591
|
+
100% { transform: translateX(100%); }
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
.upload-progress {
|
|
595
|
+
width: 60%;
|
|
596
|
+
animation: upload-progress-anim 1.2s ease-in-out infinite;
|
|
597
|
+
}
|
|
598
|
+
</style>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Component } from 'vue'
|
|
2
|
+
|
|
3
|
+
export type UploadVariant = 'button' | 'box' | 'drag'
|
|
4
|
+
|
|
5
|
+
export type UploadFileStatus = 'uploading' | 'done' | 'error'
|
|
6
|
+
|
|
7
|
+
export interface UploadFile {
|
|
8
|
+
uid?: string | number
|
|
9
|
+
name: string
|
|
10
|
+
url?: string
|
|
11
|
+
status?: UploadFileStatus
|
|
12
|
+
raw?: File | Blob
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type UploadBeforeUpload
|
|
16
|
+
= (file: File) => boolean | undefined | Promise<File | Blob | boolean | undefined>
|
|
17
|
+
|
|
18
|
+
export type UploadHandler = (files: (File | Blob)[]) => Promise<unknown>
|
|
19
|
+
|
|
20
|
+
export interface UploadProps {
|
|
21
|
+
variant?: UploadVariant
|
|
22
|
+
accept?: string
|
|
23
|
+
beforeUpload?: UploadBeforeUpload
|
|
24
|
+
upload?: UploadHandler
|
|
25
|
+
disabled?: boolean
|
|
26
|
+
readonly?: boolean
|
|
27
|
+
invalid?: boolean
|
|
28
|
+
fileList?: UploadFile[]
|
|
29
|
+
multiple?: boolean
|
|
30
|
+
maxCount?: number
|
|
31
|
+
maxSize?: number
|
|
32
|
+
text?: string
|
|
33
|
+
icon?: string | Component
|
|
34
|
+
directory?: boolean
|
|
35
|
+
class?: ClassValue
|
|
36
|
+
}
|
package/i18n/messages/ar.json
CHANGED
|
@@ -77,6 +77,27 @@
|
|
|
77
77
|
"noItems": "لا توجد خيارات",
|
|
78
78
|
"placeholder": "يرجى اختيار",
|
|
79
79
|
"searchPlaceholder": "البحث..."
|
|
80
|
+
},
|
|
81
|
+
"Upload": {
|
|
82
|
+
"drag": {
|
|
83
|
+
"title": "انقر أو اسحب الملف إلى هذه المنطقة للتحميل",
|
|
84
|
+
"titleMultiple": "انقر أو اسحب الملفات إلى هذه المنطقة للتحميل"
|
|
85
|
+
},
|
|
86
|
+
"empty": "لا توجد ملفات",
|
|
87
|
+
"error": {
|
|
88
|
+
"oversize": "{files} يتجاوز حد الحجم {size}."
|
|
89
|
+
},
|
|
90
|
+
"hint": {
|
|
91
|
+
"max": "يمكن تحديد {max} ملفات بحد أقصى.",
|
|
92
|
+
"maxSize": "الحد الأقصى للحجم لكل ملف: {size}.",
|
|
93
|
+
"multiple": "يمكن تحديد ملفات متعددة.",
|
|
94
|
+
"single": "يمكن تحديد ملف واحد فقط."
|
|
95
|
+
},
|
|
96
|
+
"preview": "معاينة",
|
|
97
|
+
"remove": "إزالة",
|
|
98
|
+
"upload": "تحميل",
|
|
99
|
+
"uploadDirectory": "تحميل الدليل",
|
|
100
|
+
"uploading": "جاري التحميل"
|
|
80
101
|
}
|
|
81
102
|
}
|
|
82
103
|
}
|
package/i18n/messages/de.json
CHANGED
|
@@ -77,6 +77,27 @@
|
|
|
77
77
|
"noItems": "Keine Optionen",
|
|
78
78
|
"placeholder": "Bitte wählen",
|
|
79
79
|
"searchPlaceholder": "Suchen..."
|
|
80
|
+
},
|
|
81
|
+
"Upload": {
|
|
82
|
+
"drag": {
|
|
83
|
+
"title": "Klicken oder ziehen Sie eine Datei in diesen Bereich, um sie hochzuladen",
|
|
84
|
+
"titleMultiple": "Klicken oder ziehen Sie Dateien in diesen Bereich, um sie hochzuladen"
|
|
85
|
+
},
|
|
86
|
+
"empty": "Keine Dateien",
|
|
87
|
+
"error": {
|
|
88
|
+
"oversize": "{files} überschreitet das {size} Größenlimit."
|
|
89
|
+
},
|
|
90
|
+
"hint": {
|
|
91
|
+
"max": "Es können bis zu {max} Dateien ausgewählt werden.",
|
|
92
|
+
"maxSize": "Maximale Dateigröße: {size}.",
|
|
93
|
+
"multiple": "Es können mehrere Dateien ausgewählt werden.",
|
|
94
|
+
"single": "Es kann nur eine einzelne Datei ausgewählt werden."
|
|
95
|
+
},
|
|
96
|
+
"preview": "Vorschau",
|
|
97
|
+
"remove": "Entfernen",
|
|
98
|
+
"upload": "Hochladen",
|
|
99
|
+
"uploadDirectory": "Verzeichnis hochladen",
|
|
100
|
+
"uploading": "Wird hochgeladen"
|
|
80
101
|
}
|
|
81
102
|
}
|
|
82
103
|
}
|