@falcondev-oss/nuxt-layers-base 0.40.3 → 0.41.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.
Files changed (61) hide show
  1. package/app/app.tsx +14 -0
  2. package/app/assets/css/base.css +6 -0
  3. package/app/components/layout/LayoutAuth.tsx +22 -0
  4. package/app/components/layout/LayoutNavbar.tsx +193 -0
  5. package/app/components/layout/LayoutPage.tsx +183 -0
  6. package/app/components/layout/LayoutSidebar.tsx +167 -0
  7. package/app/components/modals/ConfirmModal.tsx +80 -0
  8. package/app/components/modals/EntityCrudModal.tsx +124 -0
  9. package/app/components/overlay/modal/ModalActions.tsx +36 -0
  10. package/app/components/overlay/modal/ModalConfirm.tsx +46 -0
  11. package/app/components/overlay/modal/ModalRadioGroup.tsx +62 -0
  12. package/app/components/u/UActions.tsx +30 -0
  13. package/app/components/u/UAuthForm.tsx +30 -0
  14. package/app/components/u/UCustomApp.tsx +33 -0
  15. package/app/components/u/UField.tsx +211 -0
  16. package/app/components/u/UForm.tsx +133 -0
  17. package/app/components/u/UImageWithFallback.tsx +47 -0
  18. package/app/components/u/UInputDatePicker.tsx +94 -0
  19. package/app/components/u/UInputDateRangePicker.tsx +143 -0
  20. package/app/components/u/UInputDurationMinutes.tsx +106 -0
  21. package/app/components/u/UInputFile.tsx +132 -0
  22. package/app/components/u/URibbonCard.tsx +54 -0
  23. package/app/components/u/URibbonSection.tsx +36 -0
  24. package/app/components/u/USwitchCard.tsx +64 -0
  25. package/app/components/util/Define.tsx +21 -0
  26. package/app/components/util/SlotRef.tsx +21 -0
  27. package/app/components/util/Sync.tsx +37 -0
  28. package/app/components/util/TemplateText.tsx +31 -0
  29. package/app/composables/confirm.ts +1 -1
  30. package/app/error.tsx +24 -0
  31. package/app/utils/define-setup-component.ts +39 -5
  32. package/nuxt.config.ts +8 -0
  33. package/package.json +4 -3
  34. package/app/app.vue +0 -3
  35. package/app/components/layout/LayoutAuth.vue +0 -13
  36. package/app/components/layout/LayoutNavbar.vue +0 -140
  37. package/app/components/layout/LayoutPage.vue +0 -174
  38. package/app/components/layout/LayoutSidebar.vue +0 -117
  39. package/app/components/modals/ConfirmModal.vue +0 -65
  40. package/app/components/modals/EntityCrudModal.vue +0 -109
  41. package/app/components/overlay/modal/ModalActions.vue +0 -25
  42. package/app/components/overlay/modal/ModalConfirm.vue +0 -25
  43. package/app/components/overlay/modal/ModalRadioGroup.vue +0 -37
  44. package/app/components/u/UActions.vue +0 -23
  45. package/app/components/u/UAuthForm.vue +0 -36
  46. package/app/components/u/UCustomApp.vue +0 -23
  47. package/app/components/u/UField.vue +0 -191
  48. package/app/components/u/UForm.vue +0 -96
  49. package/app/components/u/UImageWithFallback.vue +0 -35
  50. package/app/components/u/UInputDatePicker.vue +0 -79
  51. package/app/components/u/UInputDateRangePicker.vue +0 -100
  52. package/app/components/u/UInputDurationMinutes.vue +0 -90
  53. package/app/components/u/UInputFile.vue +0 -127
  54. package/app/components/u/URibbonCard.vue +0 -43
  55. package/app/components/u/URibbonSection.vue +0 -29
  56. package/app/components/u/USwitchCard.vue +0 -51
  57. package/app/components/util/Define.vue +0 -13
  58. package/app/components/util/SlotRef.vue +0 -17
  59. package/app/components/util/Sync.vue +0 -24
  60. package/app/components/util/TemplateText.vue +0 -20
  61. package/app/error.vue +0 -15
@@ -0,0 +1,133 @@
1
+ import type { FormHandle } from '@falcondev-oss/form-core'
2
+ import type { ButtonProps } from '@nuxt/ui'
3
+ import type { VNode } from 'vue'
4
+ import type { Toast } from '#ui/composables'
5
+ import { Teleport } from 'vue'
6
+ import { UActions } from '#components'
7
+
8
+ export default defineSetupComponent(
9
+ (_: {
10
+ props: {
11
+ form: FormHandle
12
+ submitLabel?: string
13
+ submitButtonProps?: ButtonProps
14
+ actions?: ButtonProps[]
15
+ actionsTeleportTo?: string
16
+ disableSubmitIfUnchanged?: boolean
17
+ successToast?: Partial<Toast>
18
+ preventPageLeave?: boolean
19
+ }
20
+ slots: {
21
+ default: () => VNode[]
22
+ }
23
+ }) =>
24
+ options(_, {
25
+ name: 'UForm',
26
+ props: [
27
+ 'form',
28
+ 'submitLabel',
29
+ 'submitButtonProps',
30
+ 'actions',
31
+ 'actionsTeleportTo',
32
+ 'disableSubmitIfUnchanged',
33
+ 'successToast',
34
+ 'preventPageLeave',
35
+ ],
36
+ emits: [],
37
+ setup: (props, { slots }) => {
38
+ const preventPageLeave = () => props.preventPageLeave ?? true
39
+ const disableSubmitIfUnchanged = () => props.disableSubmitIfUnchanged ?? true
40
+
41
+ usePreventPageLeave(
42
+ () => preventPageLeave() && props.form.isChanged && !props.form.isLoading,
43
+ )
44
+
45
+ const toast = useToast()
46
+ let unhook: (() => void) | null = null
47
+ watch(
48
+ () => props.form,
49
+ (form) => {
50
+ unhook?.()
51
+ unhook = form.hooks.addHooks({
52
+ afterSubmit(result) {
53
+ if (!result.success || !props.successToast) return
54
+
55
+ toast.add({
56
+ preset: 'success',
57
+ ...props.successToast,
58
+ })
59
+ },
60
+ })
61
+ },
62
+ { immediate: true },
63
+ )
64
+
65
+ const actionsWithSubmit = computed(() => {
66
+ const submit = {
67
+ ...props.submitButtonProps,
68
+ variant: 'solid',
69
+ label: props.submitButtonProps?.label ?? props.submitLabel ?? 'Submit',
70
+ disabled: disableSubmitIfUnchanged()
71
+ ? !props.form.isChanged || props.form.isLoading
72
+ : props.form.isLoading,
73
+ loading: props.form.isLoading,
74
+ // `loadingAuto` awaits the handler, which `ButtonProps` still types as `void`
75
+ // eslint-disable-next-line ts/no-misused-promises
76
+ onClick: async () => {
77
+ await props.form.submit()
78
+ },
79
+ } satisfies ButtonProps
80
+ if (!props.actions) return [submit]
81
+
82
+ return [...props.actions, submit]
83
+ })
84
+
85
+ const rootErrors = computed(() =>
86
+ props.form.errors?.filter((error) => error.path?.length === 0),
87
+ )
88
+
89
+ return () => (
90
+ <form
91
+ class="w-full"
92
+ onSubmit={(event) => {
93
+ event.preventDefault()
94
+ void props.form.submit()
95
+ }}
96
+ >
97
+ {slots.default?.()}
98
+ <div
99
+ style={{
100
+ display: rootErrors.value?.length || !props.actionsTeleportTo ? undefined : 'none',
101
+ }}
102
+ class="col-span-full flex w-full flex-col gap-4"
103
+ >
104
+ {rootErrors.value?.length ? (
105
+ <ul class="text-error text-sm">
106
+ {rootErrors.value.map((error) => (
107
+ <li key={`${String(error.path)}:${error.message}`}>
108
+ {error.path}:{error.message}
109
+ </li>
110
+ ))}
111
+ </ul>
112
+ ) : null}
113
+
114
+ <hr class="h-px w-full text-(--ui-border-muted)" />
115
+
116
+ <div
117
+ style={{ display: props.actionsTeleportTo ? 'none' : undefined }}
118
+ class="flex items-center justify-end gap-4"
119
+ >
120
+ <Teleport defer disabled={!props.actionsTeleportTo} to={props.actionsTeleportTo}>
121
+ <UActions
122
+ defaults={{ variant: 'subtle' }}
123
+ actions={actionsWithSubmit.value}
124
+ class="contents!"
125
+ />
126
+ </Teleport>
127
+ </div>
128
+ </div>
129
+ </form>
130
+ )
131
+ },
132
+ }),
133
+ )
@@ -0,0 +1,47 @@
1
+ import type { VNode } from 'vue'
2
+ import { useImage } from '@vueuse/core'
3
+ import { USkeleton } from '#components'
4
+
5
+ export default defineSetupComponent(
6
+ (_: {
7
+ props: {
8
+ src?: string
9
+ alt?: string
10
+ fallbackSrc?: string
11
+ }
12
+ slots: {
13
+ default: () => VNode[]
14
+ }
15
+ }) =>
16
+ options(_, {
17
+ name: 'UImageWithFallback',
18
+ props: ['src', 'alt', 'fallbackSrc'],
19
+ emits: [],
20
+ setup: (props, { slots, attrs }) => {
21
+ const { error, isReady } = props.src
22
+ ? useImage(() => ({
23
+ src: props.src ?? '',
24
+ }))
25
+ : {
26
+ isReady: false,
27
+ error: true,
28
+ }
29
+
30
+ return () => {
31
+ if (toValue(isReady)) return <img src={props.src} alt={props.alt} />
32
+ if (!toValue(error)) return <USkeleton />
33
+
34
+ return (
35
+ <div>
36
+ {slots.default?.() ??
37
+ (props.fallbackSrc ? (
38
+ <img src={props.fallbackSrc} {...attrs} />
39
+ ) : (
40
+ <USkeleton {...attrs} />
41
+ ))}
42
+ </div>
43
+ )
44
+ }
45
+ },
46
+ }),
47
+ )
@@ -0,0 +1,94 @@
1
+ import type { DateValue } from '@internationalized/date'
2
+ import type { CalendarProps, InputDateProps } from '@nuxt/ui'
3
+ import type { ComponentPublicInstance } from 'vue'
4
+ import { UButton, UCalendar, UInputDate, UPopover } from '#components'
5
+
6
+ export default defineSetupComponent(
7
+ (_: {
8
+ props: {
9
+ modelValue: DateValue | null
10
+ input?: InputDateProps
11
+ calendar?: CalendarProps
12
+ disabled?: boolean
13
+ loading?: boolean
14
+ }
15
+ emits: {
16
+ 'blur': () => void
17
+ 'update:modelValue': (value: DateValue | null) => void
18
+ }
19
+ }) =>
20
+ options(_, {
21
+ name: 'UInputDatePicker',
22
+ props: ['modelValue', 'input', 'calendar', 'disabled', 'loading'],
23
+ emits: ['blur', 'update:modelValue'],
24
+ setup: (props, { emit }) => {
25
+ // only the slice of `UInputDate`'s exposed API the popover anchors to
26
+ const inputDate = ref<{ inputsRef: ComponentPublicInstance[] }>()
27
+ const open = ref(false)
28
+
29
+ watch(open, () => {
30
+ if (!open.value) emit('blur')
31
+ })
32
+
33
+ return () => (
34
+ <UInputDate
35
+ ref={inputDate}
36
+ {...props.input}
37
+ disabled={props.disabled}
38
+ loading={props.loading}
39
+ range={false}
40
+ modelValue={props.modelValue}
41
+ onBlur={() => emit('blur')}
42
+ onUpdate:modelValue={(value) => emit('update:modelValue', value ?? null)}
43
+ v-slots={vSlots(UInputDate, {
44
+ trailing: () => [
45
+ <UPopover
46
+ open={open.value}
47
+ onUpdate:open={(value) => {
48
+ open.value = value
49
+ }}
50
+ reference={inputDate.value?.inputsRef[3]?.$el as HTMLElement | undefined}
51
+ v-slots={vSlots(UPopover, {
52
+ content: () => [
53
+ <div class="p-2">
54
+ <UCalendar
55
+ {...props.calendar}
56
+ modelValue={props.modelValue ?? undefined}
57
+ multiple={false}
58
+ range={false}
59
+ onUpdate:modelValue={(value) => {
60
+ emit('update:modelValue', value ?? null)
61
+ open.value = false
62
+ }}
63
+ />
64
+ {props.modelValue ? (
65
+ <div class="flex justify-end">
66
+ <UButton
67
+ variant="ghost"
68
+ icon="tabler:trash"
69
+ color="error"
70
+ label="Löschen"
71
+ size="sm"
72
+ onClick={() => emit('update:modelValue', null)}
73
+ />
74
+ </div>
75
+ ) : null}
76
+ </div>,
77
+ ],
78
+ })}
79
+ >
80
+ <UButton
81
+ color="neutral"
82
+ variant="link"
83
+ size="sm"
84
+ icon="i-lucide-calendar"
85
+ class="px-0"
86
+ />
87
+ </UPopover>,
88
+ ],
89
+ })}
90
+ />
91
+ )
92
+ },
93
+ }),
94
+ )
@@ -0,0 +1,143 @@
1
+ import type { CalendarDate, DateValue } from '@internationalized/date'
2
+ import type { ComponentPublicInstance } from 'vue'
3
+ import { toCalendarDate } from '@internationalized/date'
4
+ import { UButton, UCalendar, UInputDate, UPopover } from '#components'
5
+
6
+ type Range = {
7
+ start: CalendarDate
8
+ end: CalendarDate
9
+ }
10
+
11
+ export default defineSetupComponent(
12
+ (_: {
13
+ props: {
14
+ modelValue: Range | null
15
+ }
16
+ emits: {
17
+ 'update:modelValue': (value: Range | null) => void
18
+ }
19
+ }) =>
20
+ options(_, {
21
+ name: 'UInputDateRangePicker',
22
+ props: ['modelValue'],
23
+ emits: ['update:modelValue'],
24
+ setup: (props, { emit }) => {
25
+ // only the slice of `UInputDate`'s exposed API the popover anchors to
26
+ const inputDate = ref<{ inputsRef: ComponentPublicInstance[] }>()
27
+
28
+ const localModel = shallowRef({
29
+ start: props.modelValue?.start,
30
+ end: props.modelValue?.end,
31
+ } as
32
+ | {
33
+ start: DateValue | undefined
34
+ end: DateValue | undefined
35
+ }
36
+ | undefined
37
+ | null)
38
+
39
+ const open = ref(false)
40
+
41
+ let syncingModelValue = false
42
+ watch(
43
+ localModel,
44
+ (newValue) => {
45
+ // prevent infinite loop
46
+ if (syncingModelValue) return
47
+
48
+ if (newValue?.start && newValue.end) {
49
+ emit('update:modelValue', {
50
+ start: toCalendarDate(newValue.start),
51
+ end: toCalendarDate(newValue.end),
52
+ })
53
+ open.value = false
54
+ } else if (!newValue?.start && !newValue?.end) {
55
+ emit('update:modelValue', null)
56
+ open.value = false
57
+ }
58
+ },
59
+ {
60
+ flush: 'sync',
61
+ },
62
+ )
63
+
64
+ watch(
65
+ () => props.modelValue,
66
+ (newValue) => {
67
+ syncingModelValue = true
68
+ localModel.value = newValue
69
+ ? {
70
+ start: newValue.start,
71
+ end: newValue.end,
72
+ }
73
+ : {
74
+ start: undefined,
75
+ end: undefined,
76
+ }
77
+ syncingModelValue = false
78
+ },
79
+ )
80
+
81
+ return () => (
82
+ <UInputDate
83
+ ref={inputDate}
84
+ modelValue={localModel.value}
85
+ onUpdate:modelValue={(value) => {
86
+ localModel.value = value
87
+ }}
88
+ range
89
+ v-slots={vSlots(UInputDate, {
90
+ trailing: () => [
91
+ <UPopover
92
+ open={open.value}
93
+ onUpdate:open={(value) => {
94
+ open.value = value
95
+ }}
96
+ reference={inputDate.value?.inputsRef[0]?.$el as HTMLElement | undefined}
97
+ v-slots={vSlots(UPopover, {
98
+ content: () => [
99
+ <div class="p-2">
100
+ <UCalendar
101
+ modelValue={localModel.value}
102
+ onUpdate:modelValue={(value) => {
103
+ localModel.value = value
104
+ }}
105
+ numberOfMonths={2}
106
+ range
107
+ />
108
+ {props.modelValue ? (
109
+ <div class="flex justify-end">
110
+ <UButton
111
+ variant="ghost"
112
+ icon="tabler:trash"
113
+ color="error"
114
+ label="Löschen"
115
+ size="sm"
116
+ onClick={() => {
117
+ localModel.value = {
118
+ start: undefined,
119
+ end: undefined,
120
+ }
121
+ }}
122
+ />
123
+ </div>
124
+ ) : null}
125
+ </div>,
126
+ ],
127
+ })}
128
+ >
129
+ <UButton
130
+ color="neutral"
131
+ variant="link"
132
+ size="sm"
133
+ icon="i-lucide-calendar"
134
+ class="px-0"
135
+ />
136
+ </UPopover>,
137
+ ],
138
+ })}
139
+ />
140
+ )
141
+ },
142
+ }),
143
+ )
@@ -0,0 +1,106 @@
1
+ import type { InputEmits, InputProps } from '@nuxt/ui'
2
+ import type { MaskOptions } from 'maska'
3
+ import { regex } from 'arkregex'
4
+ import { vMaska } from 'maska/vue'
5
+ import { withDirectives } from 'vue'
6
+ import { UInput } from '#components'
7
+
8
+ // `String.raw` would widen the pattern to `string`, and `arkregex` types the match groups off
9
+ // the literal
10
+ // eslint-disable-next-line unicorn/prefer-string-raw
11
+ const parser = regex('^-?(?<hours>\\d{1,}):?(?<minutes>\\d{1,2})?$')
12
+
13
+ const maskOptions: MaskOptions = {
14
+ // S: sign, H: hours (unlimited), 5: tens digit of minutes, D: digit of minutes
15
+ mask: 'SH:5D',
16
+ tokens: {
17
+ 'S': { pattern: /[-+]/, optional: true },
18
+ 'D': { pattern: /\d/ },
19
+ 'H': { pattern: /\d/, multiple: true },
20
+ '5': {
21
+ pattern: /[0-5]/,
22
+ transform(char) {
23
+ // clamp char to max 5, otherwise maska would just block input if user tries to input 6-9
24
+ const num = Number.parseInt(char)
25
+ if (Number.isNaN(num)) return char
26
+ return num > 5 ? '5' : char
27
+ },
28
+ },
29
+ },
30
+ }
31
+
32
+ export default defineSetupComponent(
33
+ (_: {
34
+ props: Omit<InputProps<string>, 'modelValue' | 'defaultValue' | 'modelModifiers'> & {
35
+ modelValue: number | null
36
+ showZeroMinutes?: boolean
37
+ }
38
+ // the rest reaches `UInput` as inherited attributes
39
+ propKeys: 'modelValue' | 'showZeroMinutes'
40
+ emits: AsEmits<Omit<InputEmits<string>, 'update:modelValue'>> & {
41
+ 'update:modelValue': (value: number | null) => void
42
+ }
43
+ }) =>
44
+ options(_, {
45
+ name: 'UInputDurationMinutes',
46
+ props: ['modelValue', 'showZeroMinutes'],
47
+ emits: ['blur', 'change', 'update:modelValue'],
48
+ setup: (props, { emit }) => {
49
+ const duration = computed({
50
+ get: () => {
51
+ if (props.modelValue === null) return null
52
+
53
+ const absoluteMinutes = Math.abs(props.modelValue)
54
+
55
+ const hours = Math.floor(absoluteMinutes / 60)
56
+ const minutes = absoluteMinutes % 60
57
+ const sign = props.modelValue < 0 ? '-' : ''
58
+
59
+ // don't always add :00 if minutes is 0
60
+ if (minutes > 0 || props.showZeroMinutes)
61
+ return `${sign}${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`
62
+
63
+ return `${sign}${hours.toString()}`
64
+ },
65
+ set: (val: string | null) => {
66
+ if (!val) {
67
+ emit('update:modelValue', null)
68
+ return
69
+ }
70
+
71
+ const parsed = parser.exec(val)
72
+ if (!parsed) {
73
+ emit('update:modelValue', null)
74
+ return
75
+ }
76
+
77
+ const isNegative = val.startsWith('-')
78
+
79
+ const hours = Number.parseInt(parsed.groups.hours) * 60
80
+ const minutes = parsed.groups.minutes ? Number.parseInt(parsed.groups.minutes) : 0
81
+
82
+ emit('update:modelValue', isNegative ? -(hours + minutes) : hours + minutes)
83
+ },
84
+ })
85
+
86
+ return () =>
87
+ withDirectives(
88
+ <UInput
89
+ modelValue={duration.value}
90
+ onUpdate:modelValue={(value) => {
91
+ duration.value = value
92
+ }}
93
+ modelModifiers={{
94
+ nullable: true,
95
+ lazy: true,
96
+ }}
97
+ placeholder="HH:mm"
98
+ trailingIcon="lucide:timer"
99
+ onBlur={(event) => emit('blur', event)}
100
+ onChange={(event) => emit('change', event)}
101
+ />,
102
+ [[vMaska, maskOptions]],
103
+ )
104
+ },
105
+ }),
106
+ )
@@ -0,0 +1,132 @@
1
+ import type { FileUploadEmits, FileUploadProps } from '@nuxt/ui'
2
+ import { UFileUpload } from '#components'
3
+
4
+ export default defineSetupComponent(
5
+ <Multiple extends boolean = false>(_: {
6
+ props: FileUploadProps<Multiple> & {
7
+ modelValue?: (Multiple extends true ? File[] : File) | null
8
+ /**
9
+ * Set to `false` to disable compression
10
+ */
11
+ compression?:
12
+ | boolean
13
+ | {
14
+ /**
15
+ * @default 1920
16
+ */
17
+ maxDimension?: number
18
+ /**
19
+ * @default 0.85
20
+ */
21
+ quality?: number
22
+ /**
23
+ * @default 'image/webp'
24
+ */
25
+ outputType?: string
26
+ }
27
+ }
28
+ // the rest reaches `UFileUpload` as inherited attributes
29
+ propKeys: 'modelValue' | 'multiple' | 'compression'
30
+ emits: AsEmits<FileUploadEmits> & {
31
+ 'compressed': (event: {
32
+ original: File
33
+ compressed: File
34
+ savedBytes: number
35
+ savedPercentage: number
36
+ }) => void
37
+ 'update:modelValue': (value: (Multiple extends true ? File[] : File) | null) => void
38
+ }
39
+ }) =>
40
+ options(_, {
41
+ name: 'UInputFile',
42
+ props: ['modelValue', 'multiple', 'compression'],
43
+ emits: ['change', 'compressed', 'update:modelValue'],
44
+ setup: (props, { emit }) => {
45
+ type Files = Multiple extends true ? File[] : File
46
+
47
+ const compression = () => props.compression ?? true
48
+
49
+ const compressedFiles = new WeakMap<File, File>()
50
+
51
+ async function compressImage(file: File) {
52
+ const option = compression()
53
+ if (option === false) return file
54
+ if (compressedFiles.has(file)) return compressedFiles.get(file)!
55
+
56
+ const maxDimension = typeof option === 'object' ? (option.maxDimension ?? 1920) : 1920
57
+ const quality = typeof option === 'object' ? (option.quality ?? 0.85) : 0.85
58
+ const outputType =
59
+ typeof option === 'object' ? (option.outputType ?? 'image/webp') : 'image/webp'
60
+
61
+ const img = new Image()
62
+ await new Promise((resolve, reject) => {
63
+ img.addEventListener('load', resolve)
64
+ img.addEventListener('error', reject)
65
+ img.src = URL.createObjectURL(file)
66
+ })
67
+
68
+ const scale = Math.min(1, maxDimension / Math.max(img.width, img.height))
69
+ const canvas = document.createElement('canvas')
70
+ canvas.width = img.width * scale
71
+ canvas.height = img.height * scale
72
+ const ctx = canvas.getContext('2d')!
73
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
74
+ URL.revokeObjectURL(img.src)
75
+
76
+ return new Promise<File>((resolve, reject) => {
77
+ canvas.toBlob(
78
+ (blob) => {
79
+ if (!blob) return reject(new Error('Canvas is empty'))
80
+
81
+ // eslint-disable-next-line unicorn/no-unsafe-string-replacement -- the extension comes from `outputType`, not from user input
82
+ const name = file.name.replace(/\.\w+$/, `.${outputType.split('/', 2)[1]}`)
83
+ const compressedFile = new File([blob], name, { type: outputType })
84
+
85
+ compressedFiles.set(file, compressedFile)
86
+ compressedFiles.set(compressedFile, compressedFile) // required since we set model value to the compressed file
87
+ resolve(compressedFile)
88
+
89
+ emit('compressed', {
90
+ original: file,
91
+ compressed: compressedFile,
92
+ savedBytes: file.size - compressedFile.size,
93
+ savedPercentage: ((file.size - compressedFile.size) / file.size) * 100,
94
+ })
95
+ },
96
+ outputType,
97
+ quality,
98
+ )
99
+ canvas.remove()
100
+ })
101
+ }
102
+
103
+ async function forwardFiles(files: File | File[] | null | undefined) {
104
+ if (!files) {
105
+ emit('update:modelValue', files ?? null)
106
+ return
107
+ }
108
+
109
+ const filesArray = Array.isArray(files) ? files : [files]
110
+ const compressed = await Promise.all(
111
+ filesArray.map(async (file) => {
112
+ if (!file.type.startsWith('image/')) return file
113
+ return compressImage(file)
114
+ }),
115
+ )
116
+
117
+ emit('update:modelValue', (props.multiple ? compressed : compressed[0]!) as Files)
118
+ }
119
+
120
+ return () => (
121
+ <UFileUpload
122
+ multiple={props.multiple}
123
+ modelValue={props.modelValue}
124
+ onUpdate:modelValue={(files) => {
125
+ void forwardFiles(files)
126
+ }}
127
+ onChange={(event) => emit('change', event)}
128
+ />
129
+ )
130
+ },
131
+ }),
132
+ )