@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.
- package/app/app.tsx +14 -0
- package/app/assets/css/base.css +6 -0
- package/app/components/layout/LayoutAuth.tsx +22 -0
- package/app/components/layout/LayoutNavbar.tsx +193 -0
- package/app/components/layout/LayoutPage.tsx +183 -0
- package/app/components/layout/LayoutSidebar.tsx +167 -0
- package/app/components/modals/ConfirmModal.tsx +80 -0
- package/app/components/modals/EntityCrudModal.tsx +124 -0
- package/app/components/overlay/modal/ModalActions.tsx +36 -0
- package/app/components/overlay/modal/ModalConfirm.tsx +46 -0
- package/app/components/overlay/modal/ModalRadioGroup.tsx +62 -0
- package/app/components/u/UActions.tsx +30 -0
- package/app/components/u/UAuthForm.tsx +30 -0
- package/app/components/u/UCustomApp.tsx +33 -0
- package/app/components/u/UField.tsx +211 -0
- package/app/components/u/UForm.tsx +133 -0
- package/app/components/u/UImageWithFallback.tsx +47 -0
- package/app/components/u/UInputDatePicker.tsx +94 -0
- package/app/components/u/UInputDateRangePicker.tsx +143 -0
- package/app/components/u/UInputDurationMinutes.tsx +106 -0
- package/app/components/u/UInputFile.tsx +132 -0
- package/app/components/u/URibbonCard.tsx +54 -0
- package/app/components/u/URibbonSection.tsx +36 -0
- package/app/components/u/USwitchCard.tsx +64 -0
- package/app/components/util/Define.tsx +21 -0
- package/app/components/util/SlotRef.tsx +21 -0
- package/app/components/util/Sync.tsx +37 -0
- package/app/components/util/TemplateText.tsx +31 -0
- package/app/composables/confirm.ts +1 -1
- package/app/error.tsx +24 -0
- package/app/utils/define-setup-component.ts +39 -5
- package/nuxt.config.ts +8 -0
- package/package.json +4 -3
- package/app/app.vue +0 -3
- package/app/components/layout/LayoutAuth.vue +0 -13
- package/app/components/layout/LayoutNavbar.vue +0 -140
- package/app/components/layout/LayoutPage.vue +0 -174
- package/app/components/layout/LayoutSidebar.vue +0 -117
- package/app/components/modals/ConfirmModal.vue +0 -65
- package/app/components/modals/EntityCrudModal.vue +0 -109
- package/app/components/overlay/modal/ModalActions.vue +0 -25
- package/app/components/overlay/modal/ModalConfirm.vue +0 -25
- package/app/components/overlay/modal/ModalRadioGroup.vue +0 -37
- package/app/components/u/UActions.vue +0 -23
- package/app/components/u/UAuthForm.vue +0 -36
- package/app/components/u/UCustomApp.vue +0 -23
- package/app/components/u/UField.vue +0 -191
- package/app/components/u/UForm.vue +0 -96
- package/app/components/u/UImageWithFallback.vue +0 -35
- package/app/components/u/UInputDatePicker.vue +0 -79
- package/app/components/u/UInputDateRangePicker.vue +0 -100
- package/app/components/u/UInputDurationMinutes.vue +0 -90
- package/app/components/u/UInputFile.vue +0 -127
- package/app/components/u/URibbonCard.vue +0 -43
- package/app/components/u/URibbonSection.vue +0 -29
- package/app/components/u/USwitchCard.vue +0 -51
- package/app/components/util/Define.vue +0 -13
- package/app/components/util/SlotRef.vue +0 -17
- package/app/components/util/Sync.vue +0 -24
- package/app/components/util/TemplateText.vue +0 -20
- 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
|
+
)
|