@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/README.md +45 -6
- package/lib/index.js +525 -471
- package/package.json +1 -1
- package/src/components/MasonryItem.vue +151 -55
- package/src/views/Home.vue +1 -1
package/package.json
CHANGED
|
@@ -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
|
-
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
//
|
|
96
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
//
|
|
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
|
-
<!--
|
|
175
|
-
<div
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
<!--
|
|
301
|
+
<!-- Media type indicator badge (top-left corner) -->
|
|
183
302
|
<div
|
|
184
|
-
v-
|
|
185
|
-
class="absolute
|
|
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-
|
|
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
|
-
|
package/src/views/Home.vue
CHANGED
|
@@ -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-
|
|
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>
|