@wishbone-media/spark 0.27.0 → 0.28.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wishbone-media/spark",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",
@@ -0,0 +1,202 @@
1
+ <template>
2
+ <div>
3
+ <label v-if="label" class="block text-sm font-medium text-gray-700 mb-2">{{ label }}</label>
4
+ <div v-if="modelValue" class="mb-2 relative inline-block">
5
+ <a :href="modelValue" target="_blank" rel="noopener noreferrer">
6
+ <img
7
+ :src="modelValue"
8
+ :alt="label || 'Image preview'"
9
+ :class="[height, 'w-auto object-contain border border-gray-300 rounded-md cursor-pointer hover:opacity-80 transition-opacity']"
10
+ />
11
+ </a>
12
+ <button
13
+ type="button"
14
+ class="absolute -top-2 -right-2 w-5 h-5 bg-white/80 hover:bg-white text-gray-500 hover:text-gray-700 rounded-full flex items-center justify-center shadow-sm border border-gray-200"
15
+ :disabled="disabled"
16
+ @click="clearImage"
17
+ >
18
+ <font-awesome-icon :icon="Icons.farXmark" class="text-xs" />
19
+ </button>
20
+ </div>
21
+ <div
22
+ v-if="!modelValue"
23
+ class="relative rounded-md transition-colors"
24
+ :class="{
25
+ 'ring-2 ring-blue-500 ring-offset-2 bg-blue-50': isDragging,
26
+ }"
27
+ @dragover.prevent="onDragOver"
28
+ @dragenter.prevent="onDragOver"
29
+ @dragleave.prevent="onDragLeave"
30
+ @drop.prevent="onDrop"
31
+ >
32
+ <FormKit
33
+ type="file"
34
+ :name="name"
35
+ :accept="accept"
36
+ :disabled="disabled || uploading"
37
+ @input="handleFormKitInput"
38
+ />
39
+ <div
40
+ v-if="isDragging"
41
+ class="absolute inset-0 flex items-center justify-center bg-blue-50/80 rounded-md pointer-events-none"
42
+ >
43
+ <span class="text-sm text-blue-600 font-medium">Drop image here</span>
44
+ </div>
45
+ </div>
46
+ <p v-if="uploading" class="text-sm text-gray-500 mt-1">Uploading...</p>
47
+ </div>
48
+ </template>
49
+
50
+ <script setup>
51
+ import { ref, inject } from 'vue'
52
+ import { Icons } from '@/plugins/fontawesome'
53
+ import { sparkNotificationService } from '@/composables/sparkNotificationService'
54
+
55
+ const props = defineProps({
56
+ modelValue: {
57
+ type: String,
58
+ default: '',
59
+ },
60
+ uploadUrl: {
61
+ type: String,
62
+ required: true,
63
+ },
64
+ fieldName: {
65
+ type: String,
66
+ default: 'file',
67
+ },
68
+ extraFields: {
69
+ type: Object,
70
+ default: () => ({}),
71
+ },
72
+ label: {
73
+ type: String,
74
+ default: '',
75
+ },
76
+ name: {
77
+ type: String,
78
+ default: 'image_file',
79
+ },
80
+ height: {
81
+ type: String,
82
+ default: 'h-16',
83
+ },
84
+ accept: {
85
+ type: String,
86
+ default: 'image/*',
87
+ },
88
+ disabled: {
89
+ type: Boolean,
90
+ default: false,
91
+ },
92
+ responseParser: {
93
+ type: Function,
94
+ default: (response) => response.data.url || response.data,
95
+ },
96
+ })
97
+
98
+ const emit = defineEmits(['update:modelValue', 'upload-start', 'upload-success', 'upload-error'])
99
+
100
+ const axios = inject('axios')
101
+ const uploading = ref(false)
102
+ const isDragging = ref(false)
103
+
104
+ function clearImage() {
105
+ emit('update:modelValue', '')
106
+ }
107
+
108
+ function onDragOver() {
109
+ if (props.disabled || uploading.value) return
110
+ isDragging.value = true
111
+ }
112
+
113
+ function onDragLeave() {
114
+ isDragging.value = false
115
+ }
116
+
117
+ function onDrop(event) {
118
+ isDragging.value = false
119
+ if (props.disabled || uploading.value) return
120
+
121
+ const file = event.dataTransfer?.files?.[0]
122
+ if (!file) return
123
+
124
+ // Validate file type against accept prop
125
+ if (!isFileTypeAccepted(file)) {
126
+ sparkNotificationService.show({
127
+ type: 'danger',
128
+ message: 'File type not accepted.',
129
+ })
130
+ return
131
+ }
132
+
133
+ uploadFile(file)
134
+ }
135
+
136
+ function isFileTypeAccepted(file) {
137
+ // Parse accept prop (e.g., "image/*", "image/png,image/jpeg", ".png,.jpg")
138
+ const acceptTypes = props.accept.split(',').map((t) => t.trim())
139
+
140
+ for (const acceptType of acceptTypes) {
141
+ if (acceptType === '*/*') return true
142
+ if (acceptType.endsWith('/*')) {
143
+ // Wildcard like "image/*"
144
+ const category = acceptType.slice(0, -2)
145
+ if (file.type.startsWith(category + '/')) return true
146
+ } else if (acceptType.startsWith('.')) {
147
+ // Extension like ".png"
148
+ if (file.name.toLowerCase().endsWith(acceptType.toLowerCase())) return true
149
+ } else {
150
+ // Exact MIME type like "image/png"
151
+ if (file.type === acceptType) return true
152
+ }
153
+ }
154
+
155
+ return false
156
+ }
157
+
158
+ // Handle FormKit file input
159
+ function handleFormKitInput(files) {
160
+ if (!files || files.length === 0) return
161
+ const file = files[0].file
162
+ if (!file) return
163
+ uploadFile(file)
164
+ }
165
+
166
+ // Core upload logic
167
+ async function uploadFile(file) {
168
+ uploading.value = true
169
+ emit('upload-start')
170
+
171
+ try {
172
+ const formData = new FormData()
173
+ formData.append(props.fieldName, file)
174
+
175
+ // Add any extra fields
176
+ Object.entries(props.extraFields).forEach(([key, value]) => {
177
+ formData.append(key, value)
178
+ })
179
+
180
+ const response = await axios.post(props.uploadUrl, formData, {
181
+ headers: { 'Content-Type': 'multipart/form-data' },
182
+ })
183
+
184
+ const url = props.responseParser(response)
185
+ emit('update:modelValue', url)
186
+ emit('upload-success', response)
187
+
188
+ sparkNotificationService.show({
189
+ type: 'success',
190
+ message: 'Image uploaded!',
191
+ })
192
+ } catch (error) {
193
+ emit('upload-error', error)
194
+ sparkNotificationService.show({
195
+ type: 'danger',
196
+ message: error.response?.data?.message || 'Failed to upload image.',
197
+ })
198
+ } finally {
199
+ uploading.value = false
200
+ }
201
+ }
202
+ </script>
@@ -6,6 +6,7 @@ export { default as SparkBrandSelector } from './SparkBrandSelector.vue'
6
6
  export { default as SparkButton } from './SparkButton.vue'
7
7
  export { default as SparkButtonGroup } from './SparkButtonGroup.vue'
8
8
  export { default as SparkCard } from './SparkCard.vue'
9
+ export { default as SparkImageUpload } from './SparkImageUpload.vue'
9
10
  export { default as SparkModalContainer } from './SparkModalContainer.vue'
10
11
  export { default as SparkModalDialog } from './SparkModalDialog.vue'
11
12
  export { default as SparkOverlay } from './SparkOverlay.vue'