@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.
@@ -111,7 +111,6 @@ const handleDelete = async () => {
111
111
  </div>
112
112
  </template>
113
113
 
114
- <!-- Modal Content -->
115
114
  <template #body>
116
115
  <div class="p-4 space-y-4">
117
116
  <div class="flex items-start gap-4">
@@ -1,195 +1,92 @@
1
1
  <script setup lang="ts">
2
2
  /**
3
- * VAModalBase - Custom Modal Component
4
- * Pure Vue/Tailwind implementation without UModal
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
- closable?: boolean
20
- closeOnEscape?: boolean
21
- closeOnClickOutside?: boolean
22
- showBackdrop?: boolean
23
- backdropBlur?: boolean
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: false,
16
+ modelValue: undefined,
26
17
  title: '',
27
18
  size: 'md',
28
- closable: true,
29
- closeOnEscape: true,
30
- closeOnClickOutside: true,
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
- // Modal ref for focus trap
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) => emit('update:modelValue', val)
53
- })
54
-
55
- // Close modal
56
- const close = () => {
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 classes
102
- const sizeClasses = computed(() => {
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-md'
106
- case 'lg': return 'max-w-lg'
107
- case 'xl': return 'max-w-2xl'
108
- case 'full': return 'max-w-[calc(100vw-2rem)] max-h-[calc(100vh-2rem)] h-full'
109
- default: return 'max-w-md'
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
- <Teleport to="body">
119
- <Transition
120
- enter-active-class="duration-200 ease-out"
121
- enter-from-class="opacity-0"
122
- enter-to-class="opacity-100"
123
- leave-active-class="duration-150 ease-in"
124
- leave-from-class="opacity-100"
125
- leave-to-class="opacity-0"
126
- >
127
- <div
128
- v-if="isOpen"
129
- class="fixed inset-0 z-50 flex items-center justify-center p-4"
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
- <!-- Body -->
179
- <div class="flex-1 overflow-y-auto px-6 py-4">
180
- <slot />
181
- </div>
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
- <!-- Footer -->
184
- <div
185
- v-if="$slots.footer"
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
- </Transition>
194
- </Teleport>
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?: string
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: false,
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 !== undefined ? props.modelValue : internalOpen.value,
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
- <UCard :ui="{ ring: '', divide: 'divide-y divide-gray-100 dark:divide-gray-800' }">
70
- <template #header>
71
- <div class="flex items-center justify-between">
72
- <h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-white">
73
- {{ title }}
74
- </h3>
75
- <UButton color="gray" variant="ghost" icon="i-lucide-x" class="-my-1" @click="isOpen = false" />
76
- </div>
77
- </template>
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
- <div class="p-6">
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="mt-4 p-3 rounded bg-red-50 text-red-600 text-sm">
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
- <template v-if="$slots.footer" #footer>
101
- <slot name="footer" :submit="handleSubmit" :loading="loading || crudLoading" />
102
- </template>
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' // Variation: XL is wider here
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, overlay: { background: 'bg-gray-900/50 backdrop-blur-sm' } }"
79
+ :ui="{ width: widthClass }"
80
80
  >
81
- <div class="flex flex-col h-full bg-white dark:bg-gray-900 shadow-xl">
82
- <!-- Header -->
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
- color="gray"
89
- variant="ghost"
90
- icon="i-lucide-x"
86
+ <UButton
87
+ icon="i-lucide-x"
88
+ color="neutral"
89
+ variant="ghost"
91
90
  size="sm"
92
- class="-mr-2"
93
- @click="close"
91
+ @click="close"
94
92
  />
95
93
  </div>
94
+ </template>
96
95
 
97
- <!-- Body -->
98
- <div class="flex-1 overflow-y-auto px-6 py-4">
96
+ <template #body>
97
+ <div class="flex-1 overflow-y-auto">
99
98
  <slot />
100
99
  </div>
100
+ </template>
101
101
 
102
- <!-- Footer -->
103
- <div v-if="$slots.footer" class="px-6 py-4 border-t border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-800/50">
104
- <slot name="footer" />
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) => emit('update:modelValue', 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
- <UCard :ui="{ ring: '', divide: 'divide-y divide-gray-100 dark:divide-gray-800' }">
18
- <template #header>
19
- <div class="flex items-center justify-between">
20
- <h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-white">
21
- {{ title }}
22
- </h3>
23
- <UButton color="gray" variant="ghost" icon="i-lucide-x" class="-my-1" @click="isOpen = false" />
24
- </div>
25
- </template>
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
- <template v-if="$slots.footer" #footer>
32
- <slot name="footer" />
33
- </template>
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