@wyxos/vibe 1.6.6 → 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.
@@ -1,14 +1,31 @@
1
1
  <script setup lang="ts">
2
- import { ref, onMounted, watch } from 'vue';
2
+ import { ref, onMounted, onUnmounted, watch, computed, withDefaults } from 'vue';
3
3
 
4
- const props = defineProps<{
4
+ const props = withDefaults(defineProps<{
5
5
  item: any;
6
6
  remove?: (item: any) => void;
7
- }>();
7
+ type?: 'image' | 'video';
8
+ notFound?: boolean;
9
+ }>(), {
10
+ // Auto-read from item if not explicitly provided
11
+ type: undefined,
12
+ notFound: undefined
13
+ });
8
14
 
9
15
  const imageLoaded = ref(false);
10
16
  const imageError = ref(false);
11
17
  const imageSrc = ref<string | null>(null);
18
+ const videoLoaded = ref(false);
19
+ const videoError = ref(false);
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
+
26
+ // Auto-read from props or item object, default to 'image'
27
+ const mediaType = computed(() => props.type ?? props.item?.type ?? 'image');
28
+ const showNotFound = computed(() => props.notFound ?? props.item?.notFound ?? false);
12
29
 
13
30
  function preloadImage(src: string): Promise<void> {
14
31
  return new Promise((resolve, reject) => {
@@ -29,26 +46,89 @@ function preloadImage(src: string): Promise<void> {
29
46
  setTimeout(() => {
30
47
  imageLoaded.value = true;
31
48
  imageError.value = false;
49
+ isLoading.value = false;
32
50
  resolve();
33
51
  }, remaining);
34
52
  };
35
53
  img.onerror = () => {
36
54
  imageError.value = true;
37
55
  imageLoaded.value = false;
56
+ isLoading.value = false;
38
57
  reject(new Error('Failed to load image'));
39
58
  };
40
59
  img.src = src;
41
60
  });
42
61
  }
43
62
 
44
- onMounted(async () => {
45
- // Debug: verify component is mounting
46
- console.log('[MasonryItem] Component mounted', props.item?.id);
47
-
63
+ function preloadVideo(src: string): Promise<void> {
64
+ return new Promise((resolve, reject) => {
65
+ if (!src) {
66
+ reject(new Error('No video source provided'));
67
+ return;
68
+ }
69
+
70
+ const video = document.createElement('video');
71
+ const startTime = Date.now();
72
+ const minLoadTime = 300;
73
+
74
+ video.preload = 'metadata';
75
+ video.muted = true; // Muted for autoplay compatibility
76
+
77
+ video.onloadedmetadata = () => {
78
+ const elapsed = Date.now() - startTime;
79
+ const remaining = Math.max(0, minLoadTime - elapsed);
80
+
81
+ setTimeout(() => {
82
+ videoLoaded.value = true;
83
+ videoError.value = false;
84
+ isLoading.value = false;
85
+ resolve();
86
+ }, remaining);
87
+ };
88
+
89
+ video.onerror = () => {
90
+ videoError.value = true;
91
+ videoLoaded.value = false;
92
+ isLoading.value = false;
93
+ reject(new Error('Failed to load video'));
94
+ };
95
+
96
+ video.src = src;
97
+ });
98
+ }
99
+
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)) {
113
+ return;
114
+ }
115
+
48
116
  const src = props.item?.src;
49
- if (src) {
117
+ if (!src) return;
118
+
119
+ isLoading.value = true;
120
+
121
+ if (mediaType.value === 'video') {
122
+ videoSrc.value = src;
123
+ videoLoaded.value = false;
124
+ videoError.value = false;
125
+ try {
126
+ await preloadVideo(src);
127
+ } catch {
128
+ // Error handled by videoError state
129
+ }
130
+ } else {
50
131
  imageSrc.value = src;
51
- // Reset state to ensure spinner shows
52
132
  imageLoaded.value = false;
53
133
  imageError.value = false;
54
134
  try {
@@ -57,64 +137,183 @@ onMounted(async () => {
57
137
  // Error handled by imageError state
58
138
  }
59
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
+ }
60
180
  });
61
181
 
62
182
  watch(
63
183
  () => props.item?.src,
64
184
  async (newSrc) => {
65
- if (newSrc && newSrc !== imageSrc.value) {
66
- imageLoaded.value = false;
67
- imageError.value = false;
68
- imageSrc.value = newSrc;
69
- try {
70
- await preloadImage(newSrc);
71
- } catch {
72
- // Error handled by imageError state
185
+ if (!newSrc || showNotFound.value) return;
186
+
187
+ // Reset states when src changes
188
+ if (mediaType.value === 'video') {
189
+ if (newSrc !== videoSrc.value) {
190
+ videoLoaded.value = false;
191
+ videoError.value = false;
192
+ videoSrc.value = newSrc;
193
+ if (isInView.value) {
194
+ isLoading.value = true;
195
+ try {
196
+ await preloadVideo(newSrc);
197
+ } catch {
198
+ // Error handled by videoError state
199
+ }
200
+ }
201
+ }
202
+ } else {
203
+ if (newSrc !== imageSrc.value) {
204
+ imageLoaded.value = false;
205
+ imageError.value = false;
206
+ imageSrc.value = newSrc;
207
+ if (isInView.value) {
208
+ isLoading.value = true;
209
+ try {
210
+ await preloadImage(newSrc);
211
+ } catch {
212
+ // Error handled by imageError state
213
+ }
214
+ }
73
215
  }
74
216
  }
75
217
  }
76
218
  );
219
+
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
77
222
  </script>
78
223
 
79
224
  <template>
80
- <div class="relative w-full h-full group">
225
+ <div ref="containerRef" class="relative w-full h-full group">
81
226
  <!-- Custom slot content (replaces default if provided) -->
82
- <slot :item="item" :remove="remove" :imageLoaded="imageLoaded" :imageError="imageError">
227
+ <slot :item="item" :remove="remove" :imageLoaded="imageLoaded" :imageError="imageError" :videoLoaded="videoLoaded" :videoError="videoError" :showNotFound="showNotFound" :isLoading="isLoading" :mediaType="mediaType">
83
228
  <!-- Default content when no slot is provided -->
84
229
  <div class="w-full h-full rounded-xl overflow-hidden shadow-sm hover:shadow-md transition-all duration-300 bg-white relative">
85
- <!-- Spinner while loading -->
230
+ <!-- Not Found state -->
86
231
  <div
87
- v-if="!imageLoaded && !imageError"
88
- class="absolute inset-0 flex items-center justify-center bg-slate-100"
232
+ v-if="showNotFound"
233
+ class="absolute inset-0 flex flex-col items-center justify-center bg-slate-100 text-slate-400 text-sm p-4 text-center"
89
234
  >
90
- <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
235
+ <i class="fas fa-search text-3xl mb-3 opacity-50"></i>
236
+ <span class="font-medium">Not Found</span>
237
+ <span class="text-xs mt-1 opacity-75">This item could not be located</span>
91
238
  </div>
92
239
 
93
- <!-- Error state -->
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>
299
+ </div>
300
+
301
+ <!-- Media type indicator badge (top-left corner) -->
94
302
  <div
95
- v-if="imageError"
96
- 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'"
97
306
  >
98
- <i class="fas fa-image text-2xl mb-2 opacity-50"></i>
99
- <span>Failed to load image</span>
307
+ <i :class="mediaType === 'video' ? 'fas fa-video text-xs' : 'fas fa-image text-xs'"></i>
100
308
  </div>
101
309
 
102
- <!-- Image (only shown when loaded) -->
103
- <img
104
- v-if="imageLoaded && imageSrc"
105
- :src="imageSrc"
106
- class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
107
- loading="lazy"
108
- decoding="async"
109
- />
110
-
111
310
  <!-- Overlay Gradient -->
112
- <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"></div>
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>
113
312
 
114
313
  <!-- Remove button -->
115
314
  <button
116
315
  v-if="remove"
117
- 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"
118
317
  @click.stop="remove(item)"
119
318
  aria-label="Remove item"
120
319
  >
@@ -122,11 +321,10 @@ watch(
122
321
  </button>
123
322
 
124
323
  <!-- Item Info (Optional, visible on hover) -->
125
- <div class="absolute bottom-0 left-0 right-0 p-3 opacity-0 group-hover:opacity-100 transform translate-y-2 group-hover:translate-y-0 transition-all duration-300 delay-75">
324
+ <div class="absolute bottom-0 left-0 right-0 p-3 opacity-0 group-hover:opacity-100 transform translate-y-2 group-hover:translate-y-0 transition-all duration-300 delay-75 pointer-events-none">
126
325
  <p class="text-white text-xs font-medium truncate drop-shadow-md">Item #{{ String(item.id).split('-')[0] }}</p>
127
326
  </div>
128
327
  </div>
129
328
  </slot>
130
329
  </div>
131
330
  </template>
132
-
package/src/main.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createApp } from 'vue'
2
2
  import './style.css'
3
3
  import App from './App.vue'
4
+ import router from './router'
4
5
 
5
- createApp(App).mount('#app')
6
+ createApp(App).use(router).mount('#app')