@veristone/nuxt-v-app 0.2.0 → 0.2.2
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/V/A/Crud/Delete.vue +0 -1
- package/app/components/V/A/Modal/Base.vue +64 -167
- package/app/components/V/A/Modal/Form.vue +47 -21
- package/app/components/V/A/Slide.vue +17 -19
- package/app/components/V/Modal.vue +51 -18
- package/app/composables/useVCrud.ts +44 -6
- package/app/composables/useVToast.ts +10 -10
- package/app/layouts/default.vue +4 -2
- package/app/pages/playground/modals.vue +794 -557
- package/app/pages/test-api-auth.vue +186 -93
- package/package.json +1 -1
|
@@ -1,195 +1,92 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
/**
|
|
3
|
-
* VAModalBase -
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* Features:
|
|
7
|
-
* - Size variants: sm, md, lg, xl, full
|
|
8
|
-
* - Backdrop with optional blur
|
|
9
|
-
* - Accessible: focus trap, escape key, click outside to close
|
|
10
|
-
* - Header/body/footer slots
|
|
11
|
-
* - Smooth enter/leave transitions
|
|
3
|
+
* VAModalBase - Base Modal Component using Nuxt UI v4
|
|
4
|
+
* Simple wrapper around UModal with consistent V-App styling
|
|
12
5
|
*/
|
|
13
|
-
import { useFocusTrap } from '@vueuse/integrations/useFocusTrap'
|
|
14
|
-
|
|
15
6
|
const props = withDefaults(defineProps<{
|
|
16
7
|
modelValue?: boolean
|
|
17
8
|
title?: string
|
|
18
9
|
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
10
|
+
triggerLabel?: string
|
|
11
|
+
triggerIcon?: string
|
|
12
|
+
triggerColor?: 'primary' | 'secondary' | 'success' | 'info' | 'warning' | 'error' | 'neutral'
|
|
13
|
+
triggerVariant?: 'solid' | 'outline' | 'soft' | 'ghost' | 'link'
|
|
14
|
+
triggerSize?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
|
24
15
|
}>(), {
|
|
25
|
-
modelValue:
|
|
16
|
+
modelValue: undefined,
|
|
26
17
|
title: '',
|
|
27
18
|
size: 'md',
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
showBackdrop: true,
|
|
32
|
-
backdropBlur: true
|
|
19
|
+
triggerColor: 'primary',
|
|
20
|
+
triggerVariant: 'solid',
|
|
21
|
+
triggerSize: 'sm'
|
|
33
22
|
})
|
|
34
23
|
|
|
35
|
-
const emit = defineEmits
|
|
36
|
-
'update:modelValue': [value: boolean]
|
|
37
|
-
'open': []
|
|
38
|
-
'close': []
|
|
39
|
-
}>()
|
|
24
|
+
const emit = defineEmits(['update:modelValue', 'open', 'close'])
|
|
40
25
|
|
|
41
|
-
|
|
42
|
-
const modalRef = ref<HTMLElement | null>(null)
|
|
43
|
-
const { activate, deactivate } = useFocusTrap(modalRef, {
|
|
44
|
-
immediate: false,
|
|
45
|
-
allowOutsideClick: true,
|
|
46
|
-
escapeDeactivates: false // We handle escape ourselves
|
|
47
|
-
})
|
|
26
|
+
const internalOpen = ref(false)
|
|
48
27
|
|
|
49
|
-
// Computed open state
|
|
50
28
|
const isOpen = computed({
|
|
51
|
-
get: () => props.modelValue,
|
|
52
|
-
set: (val) =>
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if (!props.closable) return
|
|
58
|
-
isOpen.value = false
|
|
59
|
-
emit('close')
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// Open modal
|
|
63
|
-
const open = () => {
|
|
64
|
-
isOpen.value = true
|
|
65
|
-
emit('open')
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// Handle escape key
|
|
69
|
-
const handleKeydown = (e: KeyboardEvent) => {
|
|
70
|
-
if (e.key === 'Escape' && props.closeOnEscape && props.closable) {
|
|
71
|
-
close()
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// Handle click outside
|
|
76
|
-
const handleBackdropClick = (e: MouseEvent) => {
|
|
77
|
-
if (e.target === e.currentTarget && props.closeOnClickOutside && props.closable) {
|
|
78
|
-
close()
|
|
29
|
+
get: () => props.modelValue ?? internalOpen.value,
|
|
30
|
+
set: (val) => {
|
|
31
|
+
emit('update:modelValue', val)
|
|
32
|
+
internalOpen.value = val
|
|
33
|
+
if (val) emit('open')
|
|
34
|
+
else emit('close')
|
|
79
35
|
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// Watch for open state changes
|
|
83
|
-
watch(isOpen, (val) => {
|
|
84
|
-
if (val) {
|
|
85
|
-
document.body.style.overflow = 'hidden'
|
|
86
|
-
nextTick(() => {
|
|
87
|
-
activate()
|
|
88
|
-
})
|
|
89
|
-
} else {
|
|
90
|
-
document.body.style.overflow = ''
|
|
91
|
-
deactivate()
|
|
92
|
-
}
|
|
93
|
-
})
|
|
94
|
-
|
|
95
|
-
// Cleanup on unmount
|
|
96
|
-
onUnmounted(() => {
|
|
97
|
-
document.body.style.overflow = ''
|
|
98
|
-
deactivate()
|
|
99
36
|
})
|
|
100
37
|
|
|
101
|
-
// Size
|
|
102
|
-
const
|
|
38
|
+
// Size to width mapping
|
|
39
|
+
const widthClass = computed(() => {
|
|
103
40
|
switch (props.size) {
|
|
104
|
-
case 'sm': return 'max-w-sm'
|
|
105
|
-
case 'md': return 'max-w-
|
|
106
|
-
case 'lg': return 'max-w-
|
|
107
|
-
case 'xl': return 'max-w-
|
|
108
|
-
case 'full': return 'max-w-
|
|
109
|
-
default: return 'max-w-
|
|
41
|
+
case 'sm': return 'sm:max-w-sm'
|
|
42
|
+
case 'md': return 'sm:max-w-lg'
|
|
43
|
+
case 'lg': return 'sm:max-w-2xl'
|
|
44
|
+
case 'xl': return 'sm:max-w-4xl'
|
|
45
|
+
case 'full': return 'sm:max-w-full'
|
|
46
|
+
default: return 'sm:max-w-lg'
|
|
110
47
|
}
|
|
111
48
|
})
|
|
112
|
-
|
|
113
|
-
// Expose methods
|
|
114
|
-
defineExpose({ open, close })
|
|
115
49
|
</script>
|
|
116
50
|
|
|
117
51
|
<template>
|
|
118
|
-
<
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
:class="[
|
|
131
|
-
showBackdrop ? (backdropBlur ? 'bg-gray-900/60 backdrop-blur-sm' : 'bg-gray-900/60') : ''
|
|
132
|
-
]"
|
|
133
|
-
@click="handleBackdropClick"
|
|
134
|
-
@keydown="handleKeydown"
|
|
135
|
-
>
|
|
136
|
-
<Transition
|
|
137
|
-
enter-active-class="duration-200 ease-out"
|
|
138
|
-
enter-from-class="opacity-0 scale-95 translate-y-4"
|
|
139
|
-
enter-to-class="opacity-100 scale-100 translate-y-0"
|
|
140
|
-
leave-active-class="duration-150 ease-in"
|
|
141
|
-
leave-from-class="opacity-100 scale-100 translate-y-0"
|
|
142
|
-
leave-to-class="opacity-0 scale-95 translate-y-4"
|
|
143
|
-
>
|
|
144
|
-
<div
|
|
145
|
-
v-if="isOpen"
|
|
146
|
-
ref="modalRef"
|
|
147
|
-
class="relative w-full bg-white dark:bg-gray-900 rounded-xl shadow-2xl border border-gray-200 dark:border-gray-800 flex flex-col overflow-hidden"
|
|
148
|
-
:class="[sizeClasses, size === 'full' ? '' : 'max-h-[calc(100vh-2rem)]']"
|
|
149
|
-
role="dialog"
|
|
150
|
-
aria-modal="true"
|
|
151
|
-
:aria-labelledby="title ? 'va-modal-title' : undefined"
|
|
152
|
-
>
|
|
153
|
-
<!-- Header -->
|
|
154
|
-
<div
|
|
155
|
-
v-if="title || $slots.header || closable"
|
|
156
|
-
class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-800 shrink-0"
|
|
157
|
-
>
|
|
158
|
-
<slot name="header">
|
|
159
|
-
<h2
|
|
160
|
-
v-if="title"
|
|
161
|
-
id="va-modal-title"
|
|
162
|
-
class="text-lg font-semibold text-gray-900 dark:text-white"
|
|
163
|
-
>
|
|
164
|
-
{{ title }}
|
|
165
|
-
</h2>
|
|
166
|
-
</slot>
|
|
167
|
-
<button
|
|
168
|
-
v-if="closable"
|
|
169
|
-
type="button"
|
|
170
|
-
class="p-1.5 rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500"
|
|
171
|
-
aria-label="Close modal"
|
|
172
|
-
@click="close"
|
|
173
|
-
>
|
|
174
|
-
<UIcon name="i-lucide-x" class="w-5 h-5" />
|
|
175
|
-
</button>
|
|
176
|
-
</div>
|
|
52
|
+
<UModal v-model:open="isOpen" :ui="{ width: widthClass }">
|
|
53
|
+
<!-- Trigger Button (required for Nuxt UI v4) -->
|
|
54
|
+
<slot name="trigger">
|
|
55
|
+
<UButton
|
|
56
|
+
v-if="triggerLabel || triggerIcon"
|
|
57
|
+
:label="triggerLabel"
|
|
58
|
+
:icon="triggerIcon"
|
|
59
|
+
:color="triggerColor"
|
|
60
|
+
:variant="triggerVariant"
|
|
61
|
+
:size="triggerSize"
|
|
62
|
+
/>
|
|
63
|
+
</slot>
|
|
177
64
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
65
|
+
<template #header>
|
|
66
|
+
<div class="flex items-center justify-between w-full">
|
|
67
|
+
<slot name="header">
|
|
68
|
+
<h3 v-if="title" class="text-base font-semibold text-gray-900 dark:text-white">
|
|
69
|
+
{{ title }}
|
|
70
|
+
</h3>
|
|
71
|
+
</slot>
|
|
72
|
+
<UButton
|
|
73
|
+
icon="i-lucide-x"
|
|
74
|
+
color="neutral"
|
|
75
|
+
variant="ghost"
|
|
76
|
+
size="sm"
|
|
77
|
+
@click="isOpen = false"
|
|
78
|
+
/>
|
|
79
|
+
</div>
|
|
80
|
+
</template>
|
|
182
81
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
class="px-6 py-4 border-t border-gray-200 dark:border-gray-800 bg-gray-50 dark:bg-gray-800/50 shrink-0"
|
|
187
|
-
>
|
|
188
|
-
<slot name="footer" />
|
|
189
|
-
</div>
|
|
190
|
-
</div>
|
|
191
|
-
</Transition>
|
|
82
|
+
<template #body>
|
|
83
|
+
<div class="p-4">
|
|
84
|
+
<slot />
|
|
192
85
|
</div>
|
|
193
|
-
</
|
|
194
|
-
|
|
86
|
+
</template>
|
|
87
|
+
|
|
88
|
+
<template v-if="$slots.footer" #footer>
|
|
89
|
+
<slot name="footer" />
|
|
90
|
+
</template>
|
|
91
|
+
</UModal>
|
|
195
92
|
</template>
|
|
@@ -8,13 +8,22 @@ const props = withDefaults(defineProps<{
|
|
|
8
8
|
endpoint?: string
|
|
9
9
|
recordId?: string | number
|
|
10
10
|
initialData?: any
|
|
11
|
-
size?:
|
|
11
|
+
size?: 'sm' | 'md' | 'lg' | 'xl'
|
|
12
12
|
loading?: boolean
|
|
13
13
|
error?: string
|
|
14
|
+
triggerLabel?: string
|
|
15
|
+
triggerIcon?: string
|
|
16
|
+
triggerColor?: 'primary' | 'secondary' | 'success' | 'info' | 'warning' | 'error' | 'neutral'
|
|
17
|
+
triggerVariant?: 'solid' | 'outline' | 'soft' | 'ghost' | 'link'
|
|
18
|
+
triggerSize?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
|
14
19
|
}>(), {
|
|
15
|
-
modelValue:
|
|
20
|
+
modelValue: undefined,
|
|
16
21
|
title: 'Edit Record',
|
|
17
|
-
size: 'md'
|
|
22
|
+
size: 'md',
|
|
23
|
+
triggerIcon: 'i-lucide-edit',
|
|
24
|
+
triggerColor: 'primary',
|
|
25
|
+
triggerVariant: 'solid',
|
|
26
|
+
triggerSize: 'sm'
|
|
18
27
|
})
|
|
19
28
|
|
|
20
29
|
const emit = defineEmits(['update:modelValue', 'submit', 'success', 'error'])
|
|
@@ -25,7 +34,7 @@ const formData = ref({ ...(props.initialData || {}) })
|
|
|
25
34
|
const isEdit = computed(() => !!props.recordId)
|
|
26
35
|
|
|
27
36
|
const isOpen = computed({
|
|
28
|
-
get: () => props.modelValue
|
|
37
|
+
get: () => props.modelValue ?? internalOpen.value,
|
|
29
38
|
set: (val) => {
|
|
30
39
|
emit('update:modelValue', val)
|
|
31
40
|
internalOpen.value = val
|
|
@@ -65,18 +74,36 @@ const handleSubmit = async (data: any = formData.value) => {
|
|
|
65
74
|
</script>
|
|
66
75
|
|
|
67
76
|
<template>
|
|
68
|
-
<UModal v-model="isOpen" :ui="{ width: size === 'lg' ? 'sm:max-w-4xl' : 'sm:max-w-xl' }">
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
77
|
+
<UModal v-model:open="isOpen" :ui="{ width: size === 'lg' ? 'sm:max-w-4xl' : 'sm:max-w-xl' }">
|
|
78
|
+
<!-- Trigger Button (required for Nuxt UI v4) -->
|
|
79
|
+
<slot name="trigger">
|
|
80
|
+
<UButton
|
|
81
|
+
v-if="triggerLabel || triggerIcon"
|
|
82
|
+
:label="triggerLabel"
|
|
83
|
+
:icon="triggerIcon"
|
|
84
|
+
:color="triggerColor"
|
|
85
|
+
:variant="triggerVariant"
|
|
86
|
+
:size="triggerSize"
|
|
87
|
+
/>
|
|
88
|
+
</slot>
|
|
89
|
+
|
|
90
|
+
<template #header>
|
|
91
|
+
<div class="flex items-center justify-between w-full">
|
|
92
|
+
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
|
|
93
|
+
{{ title }}
|
|
94
|
+
</h3>
|
|
95
|
+
<UButton
|
|
96
|
+
icon="i-lucide-x"
|
|
97
|
+
color="neutral"
|
|
98
|
+
variant="ghost"
|
|
99
|
+
size="sm"
|
|
100
|
+
@click="isOpen = false"
|
|
101
|
+
/>
|
|
102
|
+
</div>
|
|
103
|
+
</template>
|
|
78
104
|
|
|
79
|
-
|
|
105
|
+
<template #body>
|
|
106
|
+
<div class="p-6 space-y-4">
|
|
80
107
|
<slot
|
|
81
108
|
:state="formData"
|
|
82
109
|
:loading="loading || crudLoading"
|
|
@@ -86,20 +113,19 @@ const handleSubmit = async (data: any = formData.value) => {
|
|
|
86
113
|
<!-- Default content if no slot -->
|
|
87
114
|
<form @submit.prevent="handleSubmit()">
|
|
88
115
|
<div class="space-y-4">
|
|
89
|
-
<!-- If we knew fields we'd render them, but this is generic -->
|
|
90
116
|
<p class="text-gray-500 italic">Form content goes here...</p>
|
|
91
117
|
</div>
|
|
92
118
|
</form>
|
|
93
119
|
</slot>
|
|
94
120
|
|
|
95
|
-
<div v-if="error || errorState" class="
|
|
121
|
+
<div v-if="error || errorState" class="p-3 rounded bg-red-50 text-red-600 text-sm">
|
|
96
122
|
{{ error || errorState }}
|
|
97
123
|
</div>
|
|
98
124
|
</div>
|
|
125
|
+
</template>
|
|
99
126
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
</UCard>
|
|
127
|
+
<template v-if="$slots.footer" #footer>
|
|
128
|
+
<slot name="footer" :submit="handleSubmit" :loading="loading || crudLoading" />
|
|
129
|
+
</template>
|
|
104
130
|
</UModal>
|
|
105
131
|
</template>
|
|
@@ -50,7 +50,7 @@ const widthClass = computed(() => {
|
|
|
50
50
|
case 'sm': return 'max-w-xs'
|
|
51
51
|
case 'md': return 'max-w-md'
|
|
52
52
|
case 'lg': return 'max-w-lg'
|
|
53
|
-
case 'xl': return 'max-w-2xl'
|
|
53
|
+
case 'xl': return 'max-w-2xl'
|
|
54
54
|
case 'full': return 'max-w-full'
|
|
55
55
|
default: return 'max-w-md'
|
|
56
56
|
}
|
|
@@ -74,36 +74,34 @@ const widthClass = computed(() => {
|
|
|
74
74
|
|
|
75
75
|
<!-- Slideover -->
|
|
76
76
|
<USlideover
|
|
77
|
-
v-model="isOpen"
|
|
77
|
+
v-model:open="isOpen"
|
|
78
78
|
:side="side"
|
|
79
|
-
:ui="{ width: widthClass
|
|
79
|
+
:ui="{ width: widthClass }"
|
|
80
80
|
>
|
|
81
|
-
<
|
|
82
|
-
|
|
83
|
-
<div class="px-6 py-4 flex items-center justify-between border-b border-gray-100 dark:border-gray-800">
|
|
81
|
+
<template #header>
|
|
82
|
+
<div class="flex items-center justify-between w-full">
|
|
84
83
|
<slot name="header">
|
|
85
84
|
<h2 class="text-lg font-bold text-gray-900 dark:text-white">{{ title }}</h2>
|
|
86
85
|
</slot>
|
|
87
|
-
<UButton
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
86
|
+
<UButton
|
|
87
|
+
icon="i-lucide-x"
|
|
88
|
+
color="neutral"
|
|
89
|
+
variant="ghost"
|
|
91
90
|
size="sm"
|
|
92
|
-
|
|
93
|
-
@click="close"
|
|
91
|
+
@click="close"
|
|
94
92
|
/>
|
|
95
93
|
</div>
|
|
94
|
+
</template>
|
|
96
95
|
|
|
97
|
-
|
|
98
|
-
<div class="flex-1 overflow-y-auto
|
|
96
|
+
<template #body>
|
|
97
|
+
<div class="flex-1 overflow-y-auto">
|
|
99
98
|
<slot />
|
|
100
99
|
</div>
|
|
100
|
+
</template>
|
|
101
101
|
|
|
102
|
-
|
|
103
|
-
<
|
|
104
|
-
|
|
105
|
-
</div>
|
|
106
|
-
</div>
|
|
102
|
+
<template v-if="$slots.footer" #footer>
|
|
103
|
+
<slot name="footer" />
|
|
104
|
+
</template>
|
|
107
105
|
</USlideover>
|
|
108
106
|
</div>
|
|
109
107
|
</template>
|
|
@@ -1,36 +1,69 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
const props = defineProps<{
|
|
2
|
+
const props = withDefaults(defineProps<{
|
|
3
3
|
title?: string
|
|
4
4
|
modelValue?: boolean
|
|
5
|
-
|
|
5
|
+
triggerLabel?: string
|
|
6
|
+
triggerIcon?: string
|
|
7
|
+
triggerColor?: 'primary' | 'secondary' | 'success' | 'info' | 'warning' | 'error' | 'neutral'
|
|
8
|
+
triggerVariant?: 'solid' | 'outline' | 'soft' | 'ghost' | 'link'
|
|
9
|
+
triggerSize?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
|
10
|
+
}>(), {
|
|
11
|
+
modelValue: undefined,
|
|
12
|
+
triggerColor: 'primary',
|
|
13
|
+
triggerVariant: 'solid',
|
|
14
|
+
triggerSize: 'sm'
|
|
15
|
+
})
|
|
6
16
|
|
|
7
17
|
const emit = defineEmits(['update:modelValue'])
|
|
8
18
|
|
|
19
|
+
const internalOpen = ref(false)
|
|
20
|
+
|
|
9
21
|
const isOpen = computed({
|
|
10
|
-
get: () => props.modelValue,
|
|
11
|
-
set: (val) =>
|
|
22
|
+
get: () => props.modelValue ?? internalOpen.value,
|
|
23
|
+
set: (val) => {
|
|
24
|
+
emit('update:modelValue', val)
|
|
25
|
+
internalOpen.value = val
|
|
26
|
+
}
|
|
12
27
|
})
|
|
13
28
|
</script>
|
|
14
29
|
|
|
15
30
|
<template>
|
|
16
|
-
<UModal v-model="isOpen">
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
31
|
+
<UModal v-model:open="isOpen">
|
|
32
|
+
<!-- Trigger Button (required for Nuxt UI v4) -->
|
|
33
|
+
<slot name="trigger">
|
|
34
|
+
<UButton
|
|
35
|
+
v-if="triggerLabel || triggerIcon"
|
|
36
|
+
:label="triggerLabel"
|
|
37
|
+
:icon="triggerIcon"
|
|
38
|
+
:color="triggerColor"
|
|
39
|
+
:variant="triggerVariant"
|
|
40
|
+
:size="triggerSize"
|
|
41
|
+
/>
|
|
42
|
+
</slot>
|
|
43
|
+
|
|
44
|
+
<template #header>
|
|
45
|
+
<div class="flex items-center justify-between w-full">
|
|
46
|
+
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
|
|
47
|
+
{{ title }}
|
|
48
|
+
</h3>
|
|
49
|
+
<UButton
|
|
50
|
+
icon="i-lucide-x"
|
|
51
|
+
color="neutral"
|
|
52
|
+
variant="ghost"
|
|
53
|
+
size="sm"
|
|
54
|
+
@click="isOpen = false"
|
|
55
|
+
/>
|
|
56
|
+
</div>
|
|
57
|
+
</template>
|
|
26
58
|
|
|
59
|
+
<template #body>
|
|
27
60
|
<div class="p-4">
|
|
28
61
|
<slot />
|
|
29
62
|
</div>
|
|
63
|
+
</template>
|
|
30
64
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
</UCard>
|
|
65
|
+
<template v-if="$slots.footer" #footer>
|
|
66
|
+
<slot name="footer" />
|
|
67
|
+
</template>
|
|
35
68
|
</UModal>
|
|
36
69
|
</template>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* useVCrud - Veristone CRUD Composable
|
|
3
3
|
* Optimized for V-App data flows.
|
|
4
|
-
*
|
|
4
|
+
*
|
|
5
5
|
* Uses $fetch for mutations (POST, PUT, PATCH, DELETE) to avoid Nuxt's useFetch caching issues.
|
|
6
6
|
* Uses useVFetch only for initial data loading (SSR compatible).
|
|
7
7
|
*/
|
|
@@ -156,8 +156,7 @@ export const useVCrud = <T = any>(endpoint: string, options: VCrudOptions<T> = {
|
|
|
156
156
|
onUpdated,
|
|
157
157
|
onDeleted,
|
|
158
158
|
} = options
|
|
159
|
-
|
|
160
|
-
|
|
159
|
+
|
|
161
160
|
// Renamed internal state for clarity
|
|
162
161
|
const isBusy = useState<boolean>(`v-crud-busy-${endpoint}`, () => false)
|
|
163
162
|
const lastError = useState<string | null>(`v-crud-error-${endpoint}`, () => null)
|
|
@@ -222,12 +221,12 @@ export const useVCrud = <T = any>(endpoint: string, options: VCrudOptions<T> = {
|
|
|
222
221
|
'Content-Type': 'application/json',
|
|
223
222
|
'X-Client-Version': 'v-app-1.0'
|
|
224
223
|
}
|
|
225
|
-
|
|
224
|
+
|
|
226
225
|
const token = getAuthToken()
|
|
227
226
|
if (token) {
|
|
228
227
|
headers['Authorization'] = `Bearer ${token}`
|
|
229
228
|
}
|
|
230
|
-
|
|
229
|
+
|
|
231
230
|
return headers
|
|
232
231
|
}
|
|
233
232
|
|
|
@@ -344,7 +343,7 @@ export const useVCrud = <T = any>(endpoint: string, options: VCrudOptions<T> = {
|
|
|
344
343
|
isBusy.value = true
|
|
345
344
|
lastError.value = null
|
|
346
345
|
isCreating.value = true
|
|
347
|
-
|
|
346
|
+
|
|
348
347
|
try {
|
|
349
348
|
const hasFile = payload instanceof FormData || Object.values(payload || {}).some((v: any) => v instanceof File || v instanceof FileList)
|
|
350
349
|
let body: any = payload
|
|
@@ -599,6 +598,44 @@ export const useVCrud = <T = any>(endpoint: string, options: VCrudOptions<T> = {
|
|
|
599
598
|
isBusy.value = false
|
|
600
599
|
}
|
|
601
600
|
}
|
|
601
|
+
// CUSTOM - For nested resource operations like /tapes/:id/loans
|
|
602
|
+
const custom = async <T = any>(
|
|
603
|
+
subPath: string,
|
|
604
|
+
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' = 'GET',
|
|
605
|
+
payload?: any,
|
|
606
|
+
id?: string | number
|
|
607
|
+
) => {
|
|
608
|
+
isBusy.value = true
|
|
609
|
+
lastError.value = null
|
|
610
|
+
try {
|
|
611
|
+
const url = id !== undefined
|
|
612
|
+
? `${constructUrl(endpoint, id)}/${subPath}`
|
|
613
|
+
: `${constructUrl(endpoint)}/${subPath}`
|
|
614
|
+
|
|
615
|
+
const hasFile = payload instanceof FormData || Object.values(payload || {}).some((v: any) => v instanceof File || v instanceof FileList)
|
|
616
|
+
let body: any = payload
|
|
617
|
+
if (hasFile && !(payload instanceof FormData)) {
|
|
618
|
+
body = new FormData()
|
|
619
|
+
Object.entries(payload || {}).forEach(([k, v]: any) => {
|
|
620
|
+
if (v instanceof FileList) Array.from(v).forEach(f => body.append(k, f))
|
|
621
|
+
else if (v instanceof File) body.append(k, v)
|
|
622
|
+
else if (v !== undefined && v !== null) body.append(k, String(v))
|
|
623
|
+
})
|
|
624
|
+
}
|
|
625
|
+
const result = await $fetch<T>(url, {
|
|
626
|
+
method,
|
|
627
|
+
baseURL: getBaseUrl(),
|
|
628
|
+
headers: getMutationHeaders(body),
|
|
629
|
+
body: method !== 'GET' ? body : undefined,
|
|
630
|
+
params: method === 'GET' && payload ? payload : undefined
|
|
631
|
+
})
|
|
632
|
+
return result
|
|
633
|
+
} catch (err) {
|
|
634
|
+
handleError(`Custom ${method}`, err)
|
|
635
|
+
} finally {
|
|
636
|
+
isBusy.value = false
|
|
637
|
+
}
|
|
638
|
+
}
|
|
602
639
|
|
|
603
640
|
// Auto-refresh list view when list inputs change.
|
|
604
641
|
watch([filterValues, searchTerm, sortConfig], async () => {
|
|
@@ -632,6 +669,7 @@ export const useVCrud = <T = any>(endpoint: string, options: VCrudOptions<T> = {
|
|
|
632
669
|
patch,
|
|
633
670
|
remove,
|
|
634
671
|
bulkRemove,
|
|
672
|
+
custom,
|
|
635
673
|
loading: isBusy, // Alias internal 'isBusy' to public 'loading'
|
|
636
674
|
errorState: lastError, // Alias internal 'lastError' to public 'errorState'
|
|
637
675
|
|