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