@wyxos/vibe 1.6.7 → 1.6.8

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": "@wyxos/vibe",
3
- "version": "1.6.7",
3
+ "version": "1.6.8",
4
4
  "main": "lib/index.js",
5
5
  "module": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { ref, onMounted, watch, computed, withDefaults } from 'vue';
2
+ import { ref, onMounted, onUnmounted, watch, computed, withDefaults } from 'vue';
3
3
 
4
4
  const props = withDefaults(defineProps<{
5
5
  item: any;
@@ -18,6 +18,11 @@ const imageSrc = ref<string | null>(null);
18
18
  const videoLoaded = ref(false);
19
19
  const videoError = ref(false);
20
20
  const videoSrc = ref<string | null>(null);
21
+ const isInView = ref(false);
22
+ const isLoading = ref(false);
23
+ const containerRef = ref<HTMLElement | null>(null);
24
+ let intersectionObserver: IntersectionObserver | null = null;
25
+
21
26
  // Auto-read from props or item object, default to 'image'
22
27
  const mediaType = computed(() => props.type ?? props.item?.type ?? 'image');
23
28
  const showNotFound = computed(() => props.notFound ?? props.item?.notFound ?? false);
@@ -41,12 +46,14 @@ function preloadImage(src: string): Promise<void> {
41
46
  setTimeout(() => {
42
47
  imageLoaded.value = true;
43
48
  imageError.value = false;
49
+ isLoading.value = false;
44
50
  resolve();
45
51
  }, remaining);
46
52
  };
47
53
  img.onerror = () => {
48
54
  imageError.value = true;
49
55
  imageLoaded.value = false;
56
+ isLoading.value = false;
50
57
  reject(new Error('Failed to load image'));
51
58
  };
52
59
  img.src = src;
@@ -74,6 +81,7 @@ function preloadVideo(src: string): Promise<void> {
74
81
  setTimeout(() => {
75
82
  videoLoaded.value = true;
76
83
  videoError.value = false;
84
+ isLoading.value = false;
77
85
  resolve();
78
86
  }, remaining);
79
87
  };
@@ -81,6 +89,7 @@ function preloadVideo(src: string): Promise<void> {
81
89
  video.onerror = () => {
82
90
  videoError.value = true;
83
91
  videoLoaded.value = false;
92
+ isLoading.value = false;
84
93
  reject(new Error('Failed to load video'));
85
94
  };
86
95
 
@@ -88,18 +97,27 @@ function preloadVideo(src: string): Promise<void> {
88
97
  });
89
98
  }
90
99
 
91
- onMounted(async () => {
92
- // Debug: verify component is mounting
93
- console.log('[MasonryItem] Component mounted', props.item?.id);
94
-
95
- // If notFound is true, skip preloading
96
- if (showNotFound.value) {
100
+ async function startPreloading() {
101
+ // Skip preloading if:
102
+ // - not in view
103
+ // - already loading
104
+ // - already loaded (prevent re-triggering)
105
+ // - notFound is true
106
+ if (!isInView.value || isLoading.value || showNotFound.value) {
107
+ return;
108
+ }
109
+
110
+ // Don't start preloading if media is already loaded
111
+ if ((mediaType.value === 'video' && videoLoaded.value) ||
112
+ (mediaType.value === 'image' && imageLoaded.value)) {
97
113
  return;
98
114
  }
99
-
115
+
100
116
  const src = props.item?.src;
101
117
  if (!src) return;
102
-
118
+
119
+ isLoading.value = true;
120
+
103
121
  if (mediaType.value === 'video') {
104
122
  videoSrc.value = src;
105
123
  videoLoaded.value = false;
@@ -119,6 +137,46 @@ onMounted(async () => {
119
137
  // Error handled by imageError state
120
138
  }
121
139
  }
140
+ }
141
+
142
+ onMounted(() => {
143
+ // Set up Intersection Observer to detect when item comes into view
144
+ // We set it up even for notFound items, but skip preloading
145
+ if (!containerRef.value) return;
146
+
147
+ // Use Intersection Observer to detect when item's full height is in view
148
+ // Only start preloading when the entire item is visible (intersectionRatio >= 1.0)
149
+ intersectionObserver = new IntersectionObserver(
150
+ (entries) => {
151
+ entries.forEach((entry) => {
152
+ // Only trigger when the entire item height is fully visible (intersectionRatio >= 1.0)
153
+ if (entry.isIntersecting && entry.intersectionRatio >= 1.0) {
154
+ // Only set isInView if it's not already set (prevent re-triggering)
155
+ if (!isInView.value) {
156
+ isInView.value = true;
157
+ startPreloading();
158
+ }
159
+ } else if (!entry.isIntersecting) {
160
+ // Reset isInView when item leaves viewport (optional, for re-loading if needed)
161
+ // But we don't reset here to prevent re-triggering on scroll
162
+ }
163
+ });
164
+ },
165
+ {
166
+ // Only trigger when item is 100% visible (full height in view)
167
+ threshold: [1.0]
168
+ }
169
+ );
170
+
171
+ intersectionObserver.observe(containerRef.value);
172
+ });
173
+
174
+ onUnmounted(() => {
175
+ // Clean up Intersection Observer to prevent memory leaks
176
+ if (intersectionObserver) {
177
+ intersectionObserver.disconnect();
178
+ intersectionObserver = null;
179
+ }
122
180
  });
123
181
 
124
182
  watch(
@@ -126,15 +184,19 @@ watch(
126
184
  async (newSrc) => {
127
185
  if (!newSrc || showNotFound.value) return;
128
186
 
187
+ // Reset states when src changes
129
188
  if (mediaType.value === 'video') {
130
189
  if (newSrc !== videoSrc.value) {
131
190
  videoLoaded.value = false;
132
191
  videoError.value = false;
133
192
  videoSrc.value = newSrc;
134
- try {
135
- await preloadVideo(newSrc);
136
- } catch {
137
- // Error handled by videoError state
193
+ if (isInView.value) {
194
+ isLoading.value = true;
195
+ try {
196
+ await preloadVideo(newSrc);
197
+ } catch {
198
+ // Error handled by videoError state
199
+ }
138
200
  }
139
201
  }
140
202
  } else {
@@ -142,23 +204,27 @@ watch(
142
204
  imageLoaded.value = false;
143
205
  imageError.value = false;
144
206
  imageSrc.value = newSrc;
145
- try {
146
- await preloadImage(newSrc);
147
- } catch {
148
- // Error handled by imageError state
207
+ if (isInView.value) {
208
+ isLoading.value = true;
209
+ try {
210
+ await preloadImage(newSrc);
211
+ } catch {
212
+ // Error handled by imageError state
213
+ }
149
214
  }
150
215
  }
151
216
  }
152
217
  }
153
218
  );
154
219
 
155
- // mediaType and showNotFound are now computed, so they automatically react to changes
220
+ // Note: We don't watch isInView here because startPreloading() is already called
221
+ // from the IntersectionObserver callback, and we want to prevent re-triggering
156
222
  </script>
157
223
 
158
224
  <template>
159
- <div class="relative w-full h-full group">
225
+ <div ref="containerRef" class="relative w-full h-full group">
160
226
  <!-- Custom slot content (replaces default if provided) -->
161
- <slot :item="item" :remove="remove" :imageLoaded="imageLoaded" :imageError="imageError" :videoLoaded="videoLoaded" :videoError="videoError" :showNotFound="showNotFound">
227
+ <slot :item="item" :remove="remove" :imageLoaded="imageLoaded" :imageError="imageError" :videoLoaded="videoLoaded" :videoError="videoError" :showNotFound="showNotFound" :isLoading="isLoading" :mediaType="mediaType">
162
228
  <!-- Default content when no slot is provided -->
163
229
  <div class="w-full h-full rounded-xl overflow-hidden shadow-sm hover:shadow-md transition-all duration-300 bg-white relative">
164
230
  <!-- Not Found state -->
@@ -171,52 +237,83 @@ watch(
171
237
  <span class="text-xs mt-1 opacity-75">This item could not be located</span>
172
238
  </div>
173
239
 
174
- <!-- Spinner while loading -->
175
- <div
176
- v-else-if="(mediaType === 'image' && !imageLoaded && !imageError) || (mediaType === 'video' && !videoLoaded && !videoError)"
177
- class="absolute inset-0 flex items-center justify-center bg-slate-100"
178
- >
179
- <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
240
+ <!-- Media content (image or video) -->
241
+ <div v-else class="relative w-full h-full">
242
+ <!-- Image (shown immediately when loaded, with lazy loading attribute) -->
243
+ <img
244
+ v-if="mediaType === 'image' && imageLoaded && imageSrc"
245
+ :src="imageSrc"
246
+ class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
247
+ loading="lazy"
248
+ decoding="async"
249
+ alt=""
250
+ />
251
+
252
+ <!-- Video (shown immediately when loaded) -->
253
+ <video
254
+ v-if="mediaType === 'video' && videoLoaded && videoSrc"
255
+ :src="videoSrc"
256
+ class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
257
+ muted
258
+ loop
259
+ playsinline
260
+ @mouseenter="(e) => (e.target as HTMLVideoElement).play()"
261
+ @mouseleave="(e) => (e.target as HTMLVideoElement).pause()"
262
+ @error="videoError = true"
263
+ />
264
+
265
+ <!-- Placeholder background while loading or if not loaded yet -->
266
+ <div
267
+ v-if="!imageLoaded && !videoLoaded && !imageError && !videoError"
268
+ class="absolute inset-0 bg-slate-100 flex items-center justify-center"
269
+ >
270
+ <!-- Media type indicator - shown BEFORE preloading starts -->
271
+ <div
272
+ class="flex flex-col items-center justify-center gap-2 text-slate-400"
273
+ >
274
+ <div class="w-12 h-12 rounded-full bg-white/80 backdrop-blur-sm flex items-center justify-center shadow-sm">
275
+ <i :class="mediaType === 'video' ? 'fas fa-video text-xl' : 'fas fa-image text-xl'"></i>
276
+ </div>
277
+ <span class="text-xs font-medium uppercase">{{ mediaType }}</span>
278
+ </div>
279
+ </div>
280
+
281
+ <!-- Spinner underneath the graphic (only shown when loading) -->
282
+ <div
283
+ v-if="isLoading"
284
+ class="absolute bottom-2 left-1/2 transform -translate-x-1/2 flex items-center justify-center"
285
+ >
286
+ <div class="bg-white/90 backdrop-blur-sm rounded-full px-3 py-1.5 shadow-sm">
287
+ <div class="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-500"></div>
288
+ </div>
289
+ </div>
290
+
291
+ <!-- Error state -->
292
+ <div
293
+ v-if="(mediaType === 'image' && imageError) || (mediaType === 'video' && videoError)"
294
+ class="absolute inset-0 flex flex-col items-center justify-center bg-slate-50 text-slate-400 text-sm p-4 text-center"
295
+ >
296
+ <i :class="mediaType === 'video' ? 'fas fa-video text-2xl mb-2 opacity-50' : 'fas fa-image text-2xl mb-2 opacity-50'"></i>
297
+ <span>Failed to load {{ mediaType }}</span>
298
+ </div>
180
299
  </div>
181
300
 
182
- <!-- Error state -->
301
+ <!-- Media type indicator badge (top-left corner) -->
183
302
  <div
184
- v-else-if="(mediaType === 'image' && imageError) || (mediaType === 'video' && videoError)"
185
- class="absolute inset-0 flex flex-col items-center justify-center bg-slate-50 text-slate-400 text-sm p-4 text-center"
303
+ v-if="!showNotFound && (imageLoaded || videoLoaded || isLoading)"
304
+ class="absolute top-2 left-2 w-7 h-7 flex items-center justify-center bg-black/60 backdrop-blur-sm text-white rounded-full shadow-sm opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none"
305
+ :title="mediaType === 'video' ? 'Video' : 'Image'"
186
306
  >
187
- <i :class="mediaType === 'video' ? 'fas fa-video text-2xl mb-2 opacity-50' : 'fas fa-image text-2xl mb-2 opacity-50'"></i>
188
- <span>Failed to load {{ mediaType }}</span>
307
+ <i :class="mediaType === 'video' ? 'fas fa-video text-xs' : 'fas fa-image text-xs'"></i>
189
308
  </div>
190
309
 
191
- <!-- Image (only shown when loaded) -->
192
- <img
193
- v-if="mediaType === 'image' && imageLoaded && imageSrc && !showNotFound"
194
- :src="imageSrc"
195
- class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
196
- loading="lazy"
197
- decoding="async"
198
- />
199
-
200
- <!-- Video (only shown when loaded) -->
201
- <video
202
- v-if="mediaType === 'video' && videoLoaded && videoSrc && !showNotFound"
203
- :src="videoSrc"
204
- class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
205
- muted
206
- loop
207
- playsinline
208
- @mouseenter="(e) => (e.target as HTMLVideoElement).play()"
209
- @mouseleave="(e) => (e.target as HTMLVideoElement).pause()"
210
- @error="videoError = true"
211
- />
212
-
213
310
  <!-- Overlay Gradient -->
214
311
  <div class="absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none"></div>
215
312
 
216
313
  <!-- Remove button -->
217
314
  <button
218
315
  v-if="remove"
219
- class="absolute top-2 right-2 w-8 h-8 flex items-center justify-center bg-white/90 backdrop-blur-sm text-slate-700 rounded-full shadow-sm opacity-0 group-hover:opacity-100 transform translate-y-2 group-hover:translate-y-0 transition-all duration-300 hover:bg-red-500 hover:text-white cursor-pointer"
316
+ class="absolute top-2 right-2 w-8 h-8 flex items-center justify-center bg-white/90 backdrop-blur-sm text-slate-700 rounded-full shadow-sm opacity-0 group-hover:opacity-100 transform translate-y-2 group-hover:translate-y-0 transition-all duration-300 hover:bg-red-500 hover:text-white cursor-pointer z-10"
220
317
  @click.stop="remove(item)"
221
318
  aria-label="Remove item"
222
319
  >
@@ -231,4 +328,3 @@ watch(
231
328
  </slot>
232
329
  </div>
233
330
  </template>
234
-
@@ -61,7 +61,7 @@
61
61
  v-model.number="layoutParams.sizes[key]"
62
62
  type="number"
63
63
  min="1"
64
- class="w-full px-2 py-1.5 bg-slate-50 border border-slate-200 rounded-lg text-center text-sm font-medium text-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all"
64
+ class="w-full min-w-[3rem] px-3 py-1.5 bg-slate-50 border border-slate-200 rounded-lg text-center text-sm font-medium text-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all"
65
65
  />
66
66
  </div>
67
67
  </div>