This commit is contained in:
Benjamin Canac
2025-07-17 16:06:42 +02:00
parent c577df7eb1
commit 91a3f311b1
6 changed files with 157 additions and 87 deletions

View File

@@ -35,6 +35,7 @@ const components = [
'command-palette', 'command-palette',
'drawer', 'drawer',
'dropdown-menu', 'dropdown-menu',
'file-upload',
'form', 'form',
'form-field', 'form-field',
'input', 'input',

View File

@@ -35,6 +35,7 @@ const components = [
'command-palette', 'command-palette',
'drawer', 'drawer',
'dropdown-menu', 'dropdown-menu',
'file-upload',
'form', 'form',
'form-field', 'form-field',
'input', 'input',

View File

@@ -1,5 +1,135 @@
<script setup lang="ts">
import * as z from 'zod'
import type { FormSubmitEvent } from '@nuxt/ui'
const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2MB
const MIN_DIMENSIONS = { width: 200, height: 200 }
const MAX_DIMENSIONS = { width: 4096, height: 4096 }
const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']
const formatBytes = (bytes: number, decimals = 2) => {
if (bytes === 0) return '0 Bytes'
const k = 1024
const dm = decimals < 0 ? 0 : decimals
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return Number.parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
}
const schema = z.object({
avatar: z
.instanceof(File, {
message: 'Please select an image file.'
})
.refine(file => file.size <= MAX_FILE_SIZE, {
message: `The image is too large. Please choose an image smaller than ${formatBytes(MAX_FILE_SIZE)}.`
})
.refine(file => ACCEPTED_IMAGE_TYPES.includes(file.type), {
message: 'Please upload a valid image file (JPEG, PNG, or WebP).'
})
.refine(
file =>
new Promise((resolve) => {
const reader = new FileReader()
reader.onload = (e) => {
const img = new Image()
img.onload = () => {
const meetsDimensions
= img.width >= MIN_DIMENSIONS.width
&& img.height >= MIN_DIMENSIONS.height
&& img.width <= MAX_DIMENSIONS.width
&& img.height <= MAX_DIMENSIONS.height
resolve(meetsDimensions)
}
img.src = e.target?.result as string
}
reader.readAsDataURL(file)
}),
{
message: `The image dimensions are invalid. Please upload an image between ${MIN_DIMENSIONS.width}x${MIN_DIMENSIONS.height} and ${MAX_DIMENSIONS.width}x${MAX_DIMENSIONS.height} pixels.`
}
)
})
type schema = z.output<typeof schema>
const state = reactive<Partial<schema>>({
avatar: undefined
})
const upload = useUpload('/api/blob', { method: 'PUT' })
async function onSubmit(event: FormSubmitEvent<schema>) {
const res = await upload(event.data.avatar)
console.log(res)
}
const files = ref<File[]>([
{
name: 'image-01.jpg',
size: 1528737,
type: 'image/jpeg',
url: 'https://picsum.photos/1000/800?grayscale&random=1',
id: 'image-01-123456789'
},
{
name: 'image-02.jpg',
size: 1528737,
type: 'image/jpeg',
url: 'https://picsum.photos/1000/800?grayscale&random=2',
id: 'image-02-123456789'
},
{
name: 'image-03.jpg',
size: 1528737,
type: 'image/jpeg',
url: 'https://picsum.photos/1000/800?grayscale&random=3',
id: 'image-03-123456789'
},
{
name: 'image-04.jpg',
size: 1528737,
type: 'image/jpeg',
url: 'https://picsum.photos/1000/800?grayscale&random=4',
id: 'image-04-123456789'
}
])
</script>
<template> <template>
<div> <div class="flex flex-col gap-4">
<UFileUpload /> <UForm :schema="schema" :state="state" class="space-y-4 w-80" @submit="onSubmit">
<UFormField name="avatar" label="Avatar" description="JPG, GIF or PNG. 1MB Max.">
<UFileUpload v-slot="{ open, reset, previewUrls }" v-model="state.avatar" accept="image/*">
<div class="flex flex-wrap items-center gap-3">
<UAvatar size="lg" :src="previewUrls?.[0]" icon="i-lucide-image" />
<UButton :label="state.avatar ? 'Change image' : 'Upload image'" color="neutral" @click="open()" />
</div>
<p v-if="state.avatar" class="text-xs text-muted mt-1.5">
{{ state.avatar.name }}
<UButton
label="Remove"
color="error"
variant="link"
size="xs"
class="p-0"
@click="reset()"
/>
</p>
</UFileUpload>
</UFormField>
<UButton label="Submit" type="submit" />
</UForm>
<UFileUpload v-slot="{ files, open }" multiple>
{{ files?.[0]?.name }}
<UButton label="Upload" color="neutral" @click="open()" />
</UFileUpload>
</div> </div>
</template> </template>

View File

@@ -1,81 +1,11 @@
<script setup lang="ts">
import * as z from 'zod'
import type { FormSubmitEvent } from '@nuxt/ui'
const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2MB
const MIN_DIMENSIONS = { width: 200, height: 200 }
const MAX_DIMENSIONS = { width: 4096, height: 4096 }
const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']
const formatBytes = (bytes: number, decimals = 2) => {
if (bytes === 0) return '0 Bytes'
const k = 1024
const dm = decimals < 0 ? 0 : decimals
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return Number.parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
}
const schema = z.object({
avatar: z
.instanceof(File, {
message: 'Please select an image file.'
})
.refine(file => file.size <= MAX_FILE_SIZE, {
message: `The image is too large. Please choose an image smaller than ${formatBytes(MAX_FILE_SIZE)}.`
})
.refine(file => ACCEPTED_IMAGE_TYPES.includes(file.type), {
message: 'Please upload a valid image file (JPEG, PNG, or WebP).'
})
.refine(
file =>
new Promise((resolve) => {
const reader = new FileReader()
reader.onload = (e) => {
const img = new Image()
img.onload = () => {
const meetsDimensions
= img.width >= MIN_DIMENSIONS.width
&& img.height >= MIN_DIMENSIONS.height
&& img.width <= MAX_DIMENSIONS.width
&& img.height <= MAX_DIMENSIONS.height
resolve(meetsDimensions)
}
img.src = e.target?.result as string
}
reader.readAsDataURL(file)
}),
{
message: `The image dimensions are invalid. Please upload an image between ${MIN_DIMENSIONS.width}x${MIN_DIMENSIONS.height} and ${MAX_DIMENSIONS.width}x${MAX_DIMENSIONS.height} pixels.`
}
)
})
type schema = z.output<typeof schema>
const state = reactive<Partial<schema>>({
avatar: undefined
})
const upload = useUpload('/api/blob', { method: 'PUT' })
async function onSubmit(event: FormSubmitEvent<schema>) {
const res = await upload(event.data.avatar)
console.log(res)
}
</script>
<template> <template>
<UForm :schema="schema" :state="state" class="flex flex-col gap-4" @submit="onSubmit"> <div class="text-center">
<UFormField name="avatar" label="Avatar" description="JPG, GIF or PNG. 1MB Max."> <h1 class="font-semibold text-primary mb-1">
<UFileUpload v-slot="{ open, previewUrls }" v-model="state.avatar" class="flex flex-wrap items-center gap-3" accept="image/*"> Playground
<UAvatar size="lg" :src="previewUrls?.[0]" icon="i-lucide-image" /> </h1>
<UButton label="Choose" color="neutral" @click="open()" /> <div class="flex items-center justify-center gap-1">
</UFileUpload> <UKbd value="meta" /> <UKbd value="K" />
</UFormField> </div>
</div>
<UButton label="Submit" type="submit" block />
</UForm>
</template> </template>

View File

@@ -33,6 +33,7 @@ export interface FileUploadProps<M extends boolean = false> {
* @defaultValue false * @defaultValue false
*/ */
dropzone?: boolean dropzone?: boolean
defaultValue?: File[]
class?: any class?: any
ui?: FileUpload['slots'] ui?: FileUpload['slots']
} }
@@ -46,6 +47,7 @@ export interface FileUploadSlots {
open: UseFileDialogReturn['open'] open: UseFileDialogReturn['open']
reset: UseFileDialogReturn['reset'] reset: UseFileDialogReturn['reset']
previewUrls: string[] previewUrls: string[]
files: FileList[]
}): any }): any
} }
</script> </script>
@@ -73,9 +75,8 @@ const appConfig = useAppConfig() as FileUpload['AppConfig']
const { emitFormInput, emitFormChange, id, name, disabled, ariaAttrs } = useFormField<FileUploadProps>(props, { deferInputValidation: true }) const { emitFormInput, emitFormChange, id, name, disabled, ariaAttrs } = useFormField<FileUploadProps>(props, { deferInputValidation: true })
// eslint-disable-next-line vue/no-dupe-keys
const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.fileUpload || {}) })({ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.fileUpload || {}) })({
dropzone: props.dropzone
})) }))
const inputRef = ref<HTMLInputElement>() const inputRef = ref<HTMLInputElement>()
@@ -84,14 +85,14 @@ const { files, open, reset, onCancel, onChange } = useFileDialog({
multiple: props.multiple, multiple: props.multiple,
accept: props.accept, accept: props.accept,
reset: props.reset, reset: props.reset,
input: inputRef.value input: inputRef.value,
initialFiles: props.defaultValue
}) })
const previewUrls = computed(() => Array.from(files.value || []).map(file => URL.createObjectURL(file))) const previewUrls = computed(() => Array.from(files.value || []).map(file => URL.createObjectURL(file)))
onChange((files) => { onChange((files) => {
console.log('files:', typeof files?.[0]) modelValue.value = (props.multiple ? files : files?.[0]) as (M extends true ? File[] : File) | null
modelValue.value = props.multiple ? files : files?.[0]
}) })
onCancel(() => { onCancel(() => {
@@ -101,7 +102,7 @@ onCancel(() => {
<template> <template>
<Primitive :as="as" :class="ui.root({ class: [props.ui?.root, props.class] })"> <Primitive :as="as" :class="ui.root({ class: [props.ui?.root, props.class] })">
<slot :open="open" :reset="reset" :preview-urls="previewUrls" /> <slot :open="open" :files="files" :reset="reset" :preview-urls="previewUrls" />
<input <input
:id="id" :id="id"
@@ -113,6 +114,7 @@ onCancel(() => {
:required="required" :required="required"
:disabled="disabled" :disabled="disabled"
hidden hidden
tabindex="-1"
v-bind="{ ...$attrs, ...ariaAttrs }" v-bind="{ ...$attrs, ...ariaAttrs }"
> >
</Primitive> </Primitive>

View File

@@ -1,5 +1,11 @@
export default { export default {
slots: { slots: {
root: '' root: '',
base: ''
},
variants: {
dropzone: {
base: 'border-dashed'
}
} }
} }