@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.
- 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 +27 -0
- package/app/components/ui/Upload/index.stories.ts +510 -0
- package/app/components/ui/Upload/index.vue +712 -0
- package/app/components/ui/Upload/types.ts +36 -0
- package/i18n/messages/ar.json +27 -0
- package/i18n/messages/de.json +27 -0
- package/i18n/messages/en.json +27 -0
- package/i18n/messages/es.json +27 -0
- package/i18n/messages/fr.json +27 -0
- package/i18n/messages/hi.json +27 -0
- package/i18n/messages/id.json +27 -0
- package/i18n/messages/it.json +27 -0
- package/i18n/messages/ja.json +27 -0
- package/i18n/messages/ko.json +27 -0
- package/i18n/messages/nl.json +27 -0
- package/i18n/messages/pl.json +27 -0
- package/i18n/messages/pt.json +27 -0
- package/i18n/messages/ru.json +27 -0
- package/i18n/messages/th.json +27 -0
- package/i18n/messages/tr.json +27 -0
- package/i18n/messages/vi.json +27 -0
- package/i18n/messages/zh-CN.json +27 -0
- package/i18n/messages/zh-TW.json +27 -0
- package/package.json +2 -2
|
@@ -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>
|