@wishbone-media/spark 0.27.0 → 0.29.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.29.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>
@@ -30,7 +30,8 @@
30
30
  </template>
31
31
 
32
32
  <script setup>
33
- import { computed, ref, watch } from 'vue'
33
+ import { computed, ref, watch, onMounted } from 'vue'
34
+ import { useRoute } from 'vue-router'
34
35
  import { sparkNotificationService } from '@/composables/sparkNotificationService'
35
36
  import SparkAlert from '@/components/SparkAlert.vue'
36
37
 
@@ -39,8 +40,13 @@ const props = defineProps({
39
40
  type: String,
40
41
  default: 'default',
41
42
  },
43
+ clearOnRouteChange: {
44
+ type: Boolean,
45
+ default: true,
46
+ },
42
47
  })
43
48
 
49
+ const route = useRoute()
44
50
  const outlet = computed(() => sparkNotificationService.getOutlet(props.name))
45
51
 
46
52
  // Generate unique key for each notification to trigger transition on content change
@@ -54,6 +60,43 @@ watch(
54
60
  },
55
61
  )
56
62
 
63
+ // Track which path the notification was shown on
64
+ watch(
65
+ () => outlet.value.state.isVisible,
66
+ (isVisible) => {
67
+ if (isVisible && !outlet.value.state.shownOnPath) {
68
+ outlet.value.state.shownOnPath = route.path
69
+ }
70
+ },
71
+ { immediate: true },
72
+ )
73
+
74
+ // On mount, check if notification was shown on a different route
75
+ onMounted(() => {
76
+ if (!props.clearOnRouteChange) return
77
+ if (route.meta.preserveNotifications) return
78
+ if (!outlet.value.state.isVisible) return
79
+ if (outlet.value.state.persistent) return
80
+
81
+ // If notification was shown on a different path, hide it
82
+ if (outlet.value.state.shownOnPath && outlet.value.state.shownOnPath !== route.path) {
83
+ outlet.value.hide()
84
+ }
85
+ })
86
+
87
+ // Auto-clear notification on route change (while mounted)
88
+ watch(
89
+ () => route.path,
90
+ () => {
91
+ if (!props.clearOnRouteChange) return
92
+ if (route.meta.preserveNotifications) return
93
+ if (!outlet.value.state.isVisible) return
94
+ if (outlet.value.state.persistent) return
95
+
96
+ outlet.value.hide()
97
+ },
98
+ )
99
+
57
100
  const handleClose = () => {
58
101
  sparkNotificationService.hide(props.name)
59
102
  }
@@ -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'
@@ -24,6 +24,8 @@ function createNotificationOutlet() {
24
24
  props: {},
25
25
  closeable: true,
26
26
  duration: null,
27
+ persistent: false,
28
+ shownOnPath: null,
27
29
  })
28
30
 
29
31
  // Timer state (not reactive, internal only)
@@ -90,6 +92,7 @@ function createNotificationOutlet() {
90
92
  props = {},
91
93
  closeable = true,
92
94
  duration = null,
95
+ persistent = false,
93
96
  } = options
94
97
 
95
98
  state.type = type
@@ -97,6 +100,7 @@ function createNotificationOutlet() {
97
100
  state.component = component ? markRaw(component) : null
98
101
  state.props = props
99
102
  state.closeable = closeable
103
+ state.persistent = persistent
100
104
  state.isVisible = true
101
105
 
102
106
  // Determine duration: explicit > default for type
@@ -116,6 +120,8 @@ function createNotificationOutlet() {
116
120
  state.props = {}
117
121
  state.closeable = true
118
122
  state.duration = null
123
+ state.persistent = false
124
+ state.shownOnPath = null
119
125
  }
120
126
 
121
127
  return {
@@ -187,6 +193,7 @@ class SparkNotificationService {
187
193
  * @param {Object} options.props - Props to pass to the component
188
194
  * @param {boolean} options.closeable - Whether to show close button (default: true)
189
195
  * @param {number} options.duration - Auto-dismiss duration in ms (0 = sticky, null = use default)
196
+ * @param {boolean} options.persistent - Whether notification survives route changes (default: false)
190
197
  * @param {string} outletName - Target outlet name (default: 'default')
191
198
  */
192
199
  show(options = {}, outletName = 'default') {
@@ -212,6 +219,18 @@ class SparkNotificationService {
212
219
  }
213
220
  }
214
221
 
222
+ /**
223
+ * Hide all non-persistent banner notifications
224
+ * Called by setupNotificationGuard on route change
225
+ */
226
+ hideAllNonPersistent() {
227
+ for (const outlet of this.outlets.values()) {
228
+ if (!outlet.state.persistent) {
229
+ outlet.hide()
230
+ }
231
+ }
232
+ }
233
+
215
234
  /**
216
235
  * Pause auto-dismiss timer for the specified banner outlet
217
236
  * @param {string} outletName - Target outlet name (default: 'default')