@wyxos/vibe 1.6.18 → 1.6.20

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,431 +1,431 @@
1
- <script setup lang="ts">
2
- import { ref, onMounted, onUnmounted, watch, computed, withDefaults, nextTick } from 'vue';
3
-
4
- const props = withDefaults(defineProps<{
5
- item: any;
6
- remove?: (item: any) => void;
7
- type?: 'image' | 'video';
8
- notFound?: boolean;
9
- headerHeight?: number;
10
- footerHeight?: number;
11
- // Swipe-mode integration
12
- isActive?: boolean;
13
- inSwipeMode?: boolean;
14
- }>(), {
15
- // Auto-read from item if not explicitly provided
16
- type: undefined,
17
- notFound: undefined,
18
- headerHeight: 0,
19
- footerHeight: 0,
20
- isActive: false,
21
- inSwipeMode: false
22
- });
23
-
24
- const emit = defineEmits<{
25
- (e: 'preload:success', payload: { item: any; type: 'image' | 'video'; src: string }): void
26
- (e: 'preload:error', payload: { item: any; type: 'image' | 'video'; src: string; error?: unknown }): void
27
- (e: 'mouse-enter', payload: { item: any; type: 'image' | 'video' }): void
28
- (e: 'mouse-leave', payload: { item: any; type: 'image' | 'video' }): void
29
- }>()
30
-
31
- const imageLoaded = ref(false);
32
- const imageError = ref(false);
33
- const imageSrc = ref<string | null>(null);
34
- const videoLoaded = ref(false);
35
- const videoError = ref(false);
36
- const videoSrc = ref<string | null>(null);
37
- const isInView = ref(false);
38
- const isLoading = ref(false);
39
- const showMedia = ref(false); // Controls fade-in animation
40
- const containerRef = ref<HTMLElement | null>(null);
41
- const videoEl = ref<HTMLVideoElement | null>(null);
42
- let intersectionObserver: IntersectionObserver | null = null;
43
-
44
- // Auto-read from props or item object, default to 'image'
45
- const mediaType = computed(() => props.type ?? props.item?.type ?? 'image');
46
- const showNotFound = computed(() => props.notFound ?? props.item?.notFound ?? false);
47
- const isSwipeMode = computed(() => !!props.inSwipeMode);
48
-
49
- function emitMouseEnter(type: 'image' | 'video'): void {
50
- emit('mouse-enter', { item: props.item, type });
51
- }
52
-
53
- function emitMouseLeave(type: 'image' | 'video'): void {
54
- emit('mouse-leave', { item: props.item, type });
55
- }
56
-
57
- function onVideoTap(event: Event): void {
58
- // In swipe mode, let native controls handle play/pause
59
- if (isSwipeMode.value) return;
60
-
61
- const el = event.target as HTMLVideoElement | null;
62
- if (!el) return;
63
- if (el.paused) {
64
- void el.play();
65
- } else {
66
- el.pause();
67
- }
68
- }
69
-
70
- function onVideoMouseEnter(event: Event): void {
71
- const el = event.target as HTMLVideoElement | null;
72
- if (!el) return;
73
-
74
- // In swipe mode, don't auto-play on hover
75
- if (!isSwipeMode.value) {
76
- void el.play();
77
- }
78
- emitMouseEnter('video');
79
- }
80
-
81
- function onVideoMouseLeave(event: Event): void {
82
- const el = event.target as HTMLVideoElement | null;
83
- if (!el) return;
84
-
85
- // In swipe mode, don't auto-pause on hover leave
86
- if (!isSwipeMode.value) {
87
- el.pause();
88
- }
89
- emitMouseLeave('video');
90
- }
91
-
92
- function preloadImage(src: string): Promise<void> {
93
- return new Promise((resolve, reject) => {
94
- if (!src) {
95
- const err = new Error('No image source provided');
96
- emit('preload:error', { item: props.item, type: 'image', src, error: err });
97
- reject(err);
98
- return;
99
- }
100
-
101
- const img = new Image();
102
- const startTime = Date.now();
103
- const minLoadTime = 300; // Minimum time to show spinner (300ms)
104
-
105
- img.onload = () => {
106
- const elapsed = Date.now() - startTime;
107
- const remaining = Math.max(0, minLoadTime - elapsed);
108
-
109
- // Ensure spinner shows for at least minLoadTime
110
- setTimeout(async () => {
111
- imageLoaded.value = true;
112
- imageError.value = false;
113
- isLoading.value = false;
114
- // Wait for Vue to update DOM, then trigger fade-in
115
- await nextTick();
116
- await new Promise(resolve => setTimeout(resolve, 100));
117
- showMedia.value = true;
118
- emit('preload:success', { item: props.item, type: 'image', src });
119
- resolve();
120
- }, remaining);
121
- };
122
- img.onerror = () => {
123
- imageError.value = true;
124
- imageLoaded.value = false;
125
- isLoading.value = false;
126
- const err = new Error('Failed to load image');
127
- emit('preload:error', { item: props.item, type: 'image', src, error: err });
128
- reject(err);
129
- };
130
- img.src = src;
131
- });
132
- }
133
-
134
- function preloadVideo(src: string): Promise<void> {
135
- return new Promise((resolve, reject) => {
136
- if (!src) {
137
- const err = new Error('No video source provided');
138
- emit('preload:error', { item: props.item, type: 'video', src, error: err });
139
- reject(err);
140
- return;
141
- }
142
-
143
- const video = document.createElement('video');
144
- const startTime = Date.now();
145
- const minLoadTime = 300;
146
-
147
- video.preload = 'metadata';
148
- video.muted = true; // Muted for autoplay compatibility
149
-
150
- video.onloadedmetadata = () => {
151
- const elapsed = Date.now() - startTime;
152
- const remaining = Math.max(0, minLoadTime - elapsed);
153
-
154
- setTimeout(async () => {
155
- videoLoaded.value = true;
156
- videoError.value = false;
157
- isLoading.value = false;
158
- // Wait for Vue to update DOM, then trigger fade-in
159
- await nextTick();
160
- await new Promise(resolve => setTimeout(resolve, 100));
161
- showMedia.value = true;
162
- emit('preload:success', { item: props.item, type: 'video', src });
163
- resolve();
164
- }, remaining);
165
- };
166
-
167
- video.onerror = () => {
168
- videoError.value = true;
169
- videoLoaded.value = false;
170
- isLoading.value = false;
171
- const err = new Error('Failed to load video');
172
- emit('preload:error', { item: props.item, type: 'video', src, error: err });
173
- reject(err);
174
- };
175
-
176
- video.src = src;
177
- });
178
- }
179
-
180
- async function startPreloading() {
181
- // Skip preloading if:
182
- // - not in view
183
- // - already loading
184
- // - already loaded (prevent re-triggering)
185
- // - notFound is true
186
- if (!isInView.value || isLoading.value || showNotFound.value) {
187
- return;
188
- }
189
-
190
- // Don't start preloading if media is already loaded
191
- if ((mediaType.value === 'video' && videoLoaded.value) ||
192
- (mediaType.value === 'image' && imageLoaded.value)) {
193
- return;
194
- }
195
-
196
- const src = props.item?.src;
197
- if (!src) return;
198
-
199
- isLoading.value = true;
200
- showMedia.value = false; // Reset fade-in state
201
-
202
- if (mediaType.value === 'video') {
203
- videoSrc.value = src;
204
- videoLoaded.value = false;
205
- videoError.value = false;
206
- try {
207
- await preloadVideo(src);
208
- } catch {
209
- // Error handled by videoError state
210
- }
211
- } else {
212
- imageSrc.value = src;
213
- imageLoaded.value = false;
214
- imageError.value = false;
215
- try {
216
- await preloadImage(src);
217
- } catch {
218
- // Error handled by imageError state
219
- }
220
- }
221
- }
222
-
223
- onMounted(() => {
224
- // Set up Intersection Observer to detect when item comes into view
225
- // We set it up even for notFound items, but skip preloading
226
- if (!containerRef.value) return;
227
-
228
- // Use Intersection Observer to detect when item's full height is in view
229
- // Only start preloading when the entire item is visible (intersectionRatio >= 1.0)
230
- intersectionObserver = new IntersectionObserver(
231
- (entries) => {
232
- entries.forEach((entry) => {
233
- // Only trigger when the entire item height is fully visible (intersectionRatio >= 1.0)
234
- if (entry.isIntersecting && entry.intersectionRatio >= 1.0) {
235
- // Only set isInView if it's not already set (prevent re-triggering)
236
- if (!isInView.value) {
237
- isInView.value = true;
238
- startPreloading();
239
- }
240
- } else if (!entry.isIntersecting) {
241
- // Reset isInView when item leaves viewport (optional, for re-loading if needed)
242
- // But we don't reset here to prevent re-triggering on scroll
243
- }
244
- });
245
- },
246
- {
247
- // Only trigger when item is 100% visible (full height in view)
248
- threshold: [1.0]
249
- }
250
- );
251
-
252
- intersectionObserver.observe(containerRef.value);
253
- });
254
-
255
- onUnmounted(() => {
256
- // Clean up Intersection Observer to prevent memory leaks
257
- if (intersectionObserver) {
258
- intersectionObserver.disconnect();
259
- intersectionObserver = null;
260
- }
261
- });
262
-
263
- watch(
264
- () => props.item?.src,
265
- async (newSrc) => {
266
- if (!newSrc || showNotFound.value) return;
267
-
268
- // Reset states when src changes
269
- if (mediaType.value === 'video') {
270
- if (newSrc !== videoSrc.value) {
271
- videoLoaded.value = false;
272
- videoError.value = false;
273
- videoSrc.value = newSrc;
274
- if (isInView.value) {
275
- isLoading.value = true;
276
- try {
277
- await preloadVideo(newSrc);
278
- } catch {
279
- // Error handled by videoError state
280
- }
281
- }
282
- }
283
- } else {
284
- if (newSrc !== imageSrc.value) {
285
- imageLoaded.value = false;
286
- imageError.value = false;
287
- imageSrc.value = newSrc;
288
- if (isInView.value) {
289
- isLoading.value = true;
290
- try {
291
- await preloadImage(newSrc);
292
- } catch {
293
- // Error handled by imageError state
294
- }
295
- }
296
- }
297
- }
298
- }
299
- );
300
-
301
- // Auto-play / pause in swipe mode when active item changes
302
- watch(
303
- () => props.isActive,
304
- (active) => {
305
- if (!isSwipeMode.value || !videoEl.value) return;
306
- if (active) {
307
- void videoEl.value.play();
308
- } else {
309
- videoEl.value.pause();
310
- }
311
- }
312
- );
313
-
314
- // Note: We don't watch isInView here because startPreloading() is already called
315
- // from the IntersectionObserver callback, and we want to prevent re-triggering
316
- </script>
317
-
318
- <template>
319
- <div ref="containerRef" class="relative w-full h-full flex flex-col">
320
- <!-- Header section (gutter top) -->
321
- <div
322
- v-if="headerHeight > 0"
323
- class="relative z-10"
324
- :style="{ height: `${headerHeight}px` }"
325
- >
326
- <slot name="header" :item="item" :remove="remove" :imageLoaded="imageLoaded" :imageError="imageError" :videoLoaded="videoLoaded" :videoError="videoError" :showNotFound="showNotFound" :isLoading="isLoading" :mediaType="mediaType" />
327
- </div>
328
-
329
- <!-- Body section (main content) -->
330
- <div class="flex-1 relative min-h-0">
331
- <!-- Custom slot content (replaces default if provided) -->
332
- <slot :item="item" :remove="remove" :imageLoaded="imageLoaded" :imageError="imageError" :videoLoaded="videoLoaded" :videoError="videoError" :showNotFound="showNotFound" :isLoading="isLoading" :mediaType="mediaType" :imageSrc="imageSrc" :videoSrc="videoSrc" :showMedia="showMedia">
333
- <!-- Default content when no slot is provided -->
334
- <div class="w-full h-full rounded-xl overflow-hidden shadow-sm transition-all duration-300 bg-white relative">
335
- <!-- Not Found state -->
336
- <div
337
- v-if="showNotFound"
338
- class="absolute inset-0 flex flex-col items-center justify-center bg-slate-100 text-slate-400 text-sm p-4 text-center"
339
- >
340
- <i class="fas fa-search text-3xl mb-3 opacity-50"></i>
341
- <span class="font-medium">Not Found</span>
342
- <span class="text-xs mt-1 opacity-75">This item could not be located</span>
343
- </div>
344
-
345
- <!-- Media content (image or video) -->
346
- <div v-else class="relative w-full h-full">
347
- <!-- Image (always rendered when src exists, fades in when loaded) -->
348
- <img
349
- v-if="mediaType === 'image' && imageSrc"
350
- :src="imageSrc"
351
- :class="[
352
- 'w-full h-full object-cover transition-opacity duration-700 ease-in-out',
353
- imageLoaded && showMedia ? 'opacity-100' : 'opacity-0'
354
- ]"
355
- style="position: absolute; top: 0; left: 0;"
356
- loading="lazy"
357
- decoding="async"
358
- alt=""
359
- @mouseenter="emitMouseEnter('image')"
360
- @mouseleave="emitMouseLeave('image')"
361
- />
362
-
363
- <!-- Video (always rendered when src exists, fades in when loaded) -->
364
- <video
365
- v-if="mediaType === 'video' && videoSrc"
366
- ref="videoEl"
367
- :src="videoSrc"
368
- :class="[
369
- 'w-full h-full object-cover transition-opacity duration-700 ease-in-out',
370
- videoLoaded && showMedia ? 'opacity-100' : 'opacity-0'
371
- ]"
372
- style="position: absolute; top: 0; left: 0;"
373
- muted
374
- loop
375
- playsinline
376
- :autoplay="isSwipeMode && props.isActive"
377
- :controls="isSwipeMode"
378
- @click.stop="onVideoTap"
379
- @touchend.stop.prevent="onVideoTap"
380
- @mouseenter="onVideoMouseEnter"
381
- @mouseleave="onVideoMouseLeave"
382
- @error="videoError = true"
383
- />
384
-
385
- <!-- Placeholder background while loading or if not loaded yet (fades out when media appears) -->
386
- <div
387
- v-if="!imageLoaded && !videoLoaded && !imageError && !videoError"
388
- :class="[
389
- 'absolute inset-0 bg-slate-100 flex items-center justify-center transition-opacity duration-500',
390
- showMedia ? 'opacity-0 pointer-events-none' : 'opacity-100'
391
- ]"
392
- >
393
- <!-- Media type indicator badge - shown BEFORE preloading starts -->
394
- <div class="w-12 h-12 rounded-full bg-white/80 backdrop-blur-sm flex items-center justify-center shadow-sm">
395
- <i :class="mediaType === 'video' ? 'fas fa-video text-xl text-slate-400' : 'fas fa-image text-xl text-slate-400'"></i>
396
- </div>
397
- </div>
398
-
399
- <!-- Spinner (only shown when loading) -->
400
- <div
401
- v-if="isLoading"
402
- class="absolute bottom-2 left-1/2 transform -translate-x-1/2 flex items-center justify-center z-10"
403
- >
404
- <div class="bg-white/90 backdrop-blur-sm rounded-full px-3 py-1.5 shadow-sm">
405
- <div class="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-500"></div>
406
- </div>
407
- </div>
408
-
409
- <!-- Error state -->
410
- <div
411
- v-if="(mediaType === 'image' && imageError) || (mediaType === 'video' && videoError)"
412
- class="absolute inset-0 flex flex-col items-center justify-center bg-slate-50 text-slate-400 text-sm p-4 text-center"
413
- >
414
- <i :class="mediaType === 'video' ? 'fas fa-video text-2xl mb-2 opacity-50' : 'fas fa-image text-2xl mb-2 opacity-50'"></i>
415
- <span>Failed to load {{ mediaType }}</span>
416
- </div>
417
- </div>
418
- </div>
419
- </slot>
420
- </div>
421
-
422
- <!-- Footer section (gutter bottom) -->
423
- <div
424
- v-if="footerHeight > 0"
425
- class="relative z-10"
426
- :style="{ height: `${footerHeight}px` }"
427
- >
428
- <slot name="footer" :item="item" :remove="remove" :imageLoaded="imageLoaded" :imageError="imageError" :videoLoaded="videoLoaded" :videoError="videoError" :showNotFound="showNotFound" :isLoading="isLoading" :mediaType="mediaType" />
429
- </div>
430
- </div>
431
- </template>
1
+ <script setup lang="ts">
2
+ import { ref, onMounted, onUnmounted, watch, computed, withDefaults, nextTick } from 'vue';
3
+
4
+ const props = withDefaults(defineProps<{
5
+ item: any;
6
+ remove?: (item: any) => void;
7
+ type?: 'image' | 'video';
8
+ notFound?: boolean;
9
+ headerHeight?: number;
10
+ footerHeight?: number;
11
+ // Swipe-mode integration
12
+ isActive?: boolean;
13
+ inSwipeMode?: boolean;
14
+ }>(), {
15
+ // Auto-read from item if not explicitly provided
16
+ type: undefined,
17
+ notFound: undefined,
18
+ headerHeight: 0,
19
+ footerHeight: 0,
20
+ isActive: false,
21
+ inSwipeMode: false
22
+ });
23
+
24
+ const emit = defineEmits<{
25
+ (e: 'preload:success', payload: { item: any; type: 'image' | 'video'; src: string }): void
26
+ (e: 'preload:error', payload: { item: any; type: 'image' | 'video'; src: string; error?: unknown }): void
27
+ (e: 'mouse-enter', payload: { item: any; type: 'image' | 'video' }): void
28
+ (e: 'mouse-leave', payload: { item: any; type: 'image' | 'video' }): void
29
+ }>()
30
+
31
+ const imageLoaded = ref(false);
32
+ const imageError = ref(false);
33
+ const imageSrc = ref<string | null>(null);
34
+ const videoLoaded = ref(false);
35
+ const videoError = ref(false);
36
+ const videoSrc = ref<string | null>(null);
37
+ const isInView = ref(false);
38
+ const isLoading = ref(false);
39
+ const showMedia = ref(false); // Controls fade-in animation
40
+ const containerRef = ref<HTMLElement | null>(null);
41
+ const videoEl = ref<HTMLVideoElement | null>(null);
42
+ let intersectionObserver: IntersectionObserver | null = null;
43
+
44
+ // Auto-read from props or item object, default to 'image'
45
+ const mediaType = computed(() => props.type ?? props.item?.type ?? 'image');
46
+ const showNotFound = computed(() => props.notFound ?? props.item?.notFound ?? false);
47
+ const isSwipeMode = computed(() => !!props.inSwipeMode);
48
+
49
+ function emitMouseEnter(type: 'image' | 'video'): void {
50
+ emit('mouse-enter', { item: props.item, type });
51
+ }
52
+
53
+ function emitMouseLeave(type: 'image' | 'video'): void {
54
+ emit('mouse-leave', { item: props.item, type });
55
+ }
56
+
57
+ function onVideoTap(event: Event): void {
58
+ // In swipe mode, let native controls handle play/pause
59
+ if (isSwipeMode.value) return;
60
+
61
+ const el = event.target as HTMLVideoElement | null;
62
+ if (!el) return;
63
+ if (el.paused) {
64
+ void el.play();
65
+ } else {
66
+ el.pause();
67
+ }
68
+ }
69
+
70
+ function onVideoMouseEnter(event: Event): void {
71
+ const el = event.target as HTMLVideoElement | null;
72
+ if (!el) return;
73
+
74
+ // In swipe mode, don't auto-play on hover
75
+ if (!isSwipeMode.value) {
76
+ void el.play();
77
+ }
78
+ emitMouseEnter('video');
79
+ }
80
+
81
+ function onVideoMouseLeave(event: Event): void {
82
+ const el = event.target as HTMLVideoElement | null;
83
+ if (!el) return;
84
+
85
+ // In swipe mode, don't auto-pause on hover leave
86
+ if (!isSwipeMode.value) {
87
+ el.pause();
88
+ }
89
+ emitMouseLeave('video');
90
+ }
91
+
92
+ function preloadImage(src: string): Promise<void> {
93
+ return new Promise((resolve, reject) => {
94
+ if (!src) {
95
+ const err = new Error('No image source provided');
96
+ emit('preload:error', { item: props.item, type: 'image', src, error: err });
97
+ reject(err);
98
+ return;
99
+ }
100
+
101
+ const img = new Image();
102
+ const startTime = Date.now();
103
+ const minLoadTime = 300; // Minimum time to show spinner (300ms)
104
+
105
+ img.onload = () => {
106
+ const elapsed = Date.now() - startTime;
107
+ const remaining = Math.max(0, minLoadTime - elapsed);
108
+
109
+ // Ensure spinner shows for at least minLoadTime
110
+ setTimeout(async () => {
111
+ imageLoaded.value = true;
112
+ imageError.value = false;
113
+ isLoading.value = false;
114
+ // Wait for Vue to update DOM, then trigger fade-in
115
+ await nextTick();
116
+ await new Promise(resolve => setTimeout(resolve, 100));
117
+ showMedia.value = true;
118
+ emit('preload:success', { item: props.item, type: 'image', src });
119
+ resolve();
120
+ }, remaining);
121
+ };
122
+ img.onerror = () => {
123
+ imageError.value = true;
124
+ imageLoaded.value = false;
125
+ isLoading.value = false;
126
+ const err = new Error('Failed to load image');
127
+ emit('preload:error', { item: props.item, type: 'image', src, error: err });
128
+ reject(err);
129
+ };
130
+ img.src = src;
131
+ });
132
+ }
133
+
134
+ function preloadVideo(src: string): Promise<void> {
135
+ return new Promise((resolve, reject) => {
136
+ if (!src) {
137
+ const err = new Error('No video source provided');
138
+ emit('preload:error', { item: props.item, type: 'video', src, error: err });
139
+ reject(err);
140
+ return;
141
+ }
142
+
143
+ const video = document.createElement('video');
144
+ const startTime = Date.now();
145
+ const minLoadTime = 300;
146
+
147
+ video.preload = 'metadata';
148
+ video.muted = true; // Muted for autoplay compatibility
149
+
150
+ video.onloadedmetadata = () => {
151
+ const elapsed = Date.now() - startTime;
152
+ const remaining = Math.max(0, minLoadTime - elapsed);
153
+
154
+ setTimeout(async () => {
155
+ videoLoaded.value = true;
156
+ videoError.value = false;
157
+ isLoading.value = false;
158
+ // Wait for Vue to update DOM, then trigger fade-in
159
+ await nextTick();
160
+ await new Promise(resolve => setTimeout(resolve, 100));
161
+ showMedia.value = true;
162
+ emit('preload:success', { item: props.item, type: 'video', src });
163
+ resolve();
164
+ }, remaining);
165
+ };
166
+
167
+ video.onerror = () => {
168
+ videoError.value = true;
169
+ videoLoaded.value = false;
170
+ isLoading.value = false;
171
+ const err = new Error('Failed to load video');
172
+ emit('preload:error', { item: props.item, type: 'video', src, error: err });
173
+ reject(err);
174
+ };
175
+
176
+ video.src = src;
177
+ });
178
+ }
179
+
180
+ async function startPreloading() {
181
+ // Skip preloading if:
182
+ // - not in view
183
+ // - already loading
184
+ // - already loaded (prevent re-triggering)
185
+ // - notFound is true
186
+ if (!isInView.value || isLoading.value || showNotFound.value) {
187
+ return;
188
+ }
189
+
190
+ // Don't start preloading if media is already loaded
191
+ if ((mediaType.value === 'video' && videoLoaded.value) ||
192
+ (mediaType.value === 'image' && imageLoaded.value)) {
193
+ return;
194
+ }
195
+
196
+ const src = props.item?.src;
197
+ if (!src) return;
198
+
199
+ isLoading.value = true;
200
+ showMedia.value = false; // Reset fade-in state
201
+
202
+ if (mediaType.value === 'video') {
203
+ videoSrc.value = src;
204
+ videoLoaded.value = false;
205
+ videoError.value = false;
206
+ try {
207
+ await preloadVideo(src);
208
+ } catch {
209
+ // Error handled by videoError state
210
+ }
211
+ } else {
212
+ imageSrc.value = src;
213
+ imageLoaded.value = false;
214
+ imageError.value = false;
215
+ try {
216
+ await preloadImage(src);
217
+ } catch {
218
+ // Error handled by imageError state
219
+ }
220
+ }
221
+ }
222
+
223
+ onMounted(() => {
224
+ // Set up Intersection Observer to detect when item comes into view
225
+ // We set it up even for notFound items, but skip preloading
226
+ if (!containerRef.value) return;
227
+
228
+ // Use Intersection Observer to detect when item's full height is in view
229
+ // Only start preloading when the entire item is visible (intersectionRatio >= 1.0)
230
+ intersectionObserver = new IntersectionObserver(
231
+ (entries) => {
232
+ entries.forEach((entry) => {
233
+ // Only trigger when the entire item height is fully visible (intersectionRatio >= 1.0)
234
+ if (entry.isIntersecting && entry.intersectionRatio >= 1.0) {
235
+ // Only set isInView if it's not already set (prevent re-triggering)
236
+ if (!isInView.value) {
237
+ isInView.value = true;
238
+ startPreloading();
239
+ }
240
+ } else if (!entry.isIntersecting) {
241
+ // Reset isInView when item leaves viewport (optional, for re-loading if needed)
242
+ // But we don't reset here to prevent re-triggering on scroll
243
+ }
244
+ });
245
+ },
246
+ {
247
+ // Only trigger when item is 100% visible (full height in view)
248
+ threshold: [1.0]
249
+ }
250
+ );
251
+
252
+ intersectionObserver.observe(containerRef.value);
253
+ });
254
+
255
+ onUnmounted(() => {
256
+ // Clean up Intersection Observer to prevent memory leaks
257
+ if (intersectionObserver) {
258
+ intersectionObserver.disconnect();
259
+ intersectionObserver = null;
260
+ }
261
+ });
262
+
263
+ watch(
264
+ () => props.item?.src,
265
+ async (newSrc) => {
266
+ if (!newSrc || showNotFound.value) return;
267
+
268
+ // Reset states when src changes
269
+ if (mediaType.value === 'video') {
270
+ if (newSrc !== videoSrc.value) {
271
+ videoLoaded.value = false;
272
+ videoError.value = false;
273
+ videoSrc.value = newSrc;
274
+ if (isInView.value) {
275
+ isLoading.value = true;
276
+ try {
277
+ await preloadVideo(newSrc);
278
+ } catch {
279
+ // Error handled by videoError state
280
+ }
281
+ }
282
+ }
283
+ } else {
284
+ if (newSrc !== imageSrc.value) {
285
+ imageLoaded.value = false;
286
+ imageError.value = false;
287
+ imageSrc.value = newSrc;
288
+ if (isInView.value) {
289
+ isLoading.value = true;
290
+ try {
291
+ await preloadImage(newSrc);
292
+ } catch {
293
+ // Error handled by imageError state
294
+ }
295
+ }
296
+ }
297
+ }
298
+ }
299
+ );
300
+
301
+ // Auto-play / pause in swipe mode when active item changes
302
+ watch(
303
+ () => props.isActive,
304
+ (active) => {
305
+ if (!isSwipeMode.value || !videoEl.value) return;
306
+ if (active) {
307
+ void videoEl.value.play();
308
+ } else {
309
+ videoEl.value.pause();
310
+ }
311
+ }
312
+ );
313
+
314
+ // Note: We don't watch isInView here because startPreloading() is already called
315
+ // from the IntersectionObserver callback, and we want to prevent re-triggering
316
+ </script>
317
+
318
+ <template>
319
+ <div ref="containerRef" class="relative w-full h-full flex flex-col">
320
+ <!-- Header section (gutter top) -->
321
+ <div
322
+ v-if="headerHeight > 0"
323
+ class="relative z-10"
324
+ :style="{ height: `${headerHeight}px` }"
325
+ >
326
+ <slot name="header" :item="item" :remove="remove" :imageLoaded="imageLoaded" :imageError="imageError" :videoLoaded="videoLoaded" :videoError="videoError" :showNotFound="showNotFound" :isLoading="isLoading" :mediaType="mediaType" />
327
+ </div>
328
+
329
+ <!-- Body section (main content) -->
330
+ <div class="flex-1 relative min-h-0">
331
+ <!-- Custom slot content (replaces default if provided) -->
332
+ <slot :item="item" :remove="remove" :imageLoaded="imageLoaded" :imageError="imageError" :videoLoaded="videoLoaded" :videoError="videoError" :showNotFound="showNotFound" :isLoading="isLoading" :mediaType="mediaType" :imageSrc="imageSrc" :videoSrc="videoSrc" :showMedia="showMedia">
333
+ <!-- Default content when no slot is provided -->
334
+ <div class="w-full h-full rounded-xl overflow-hidden shadow-sm transition-all duration-300 bg-white relative">
335
+ <!-- Not Found state -->
336
+ <div
337
+ v-if="showNotFound"
338
+ class="absolute inset-0 flex flex-col items-center justify-center bg-slate-100 text-slate-400 text-sm p-4 text-center"
339
+ >
340
+ <i class="fas fa-search text-3xl mb-3 opacity-50"></i>
341
+ <span class="font-medium">Not Found</span>
342
+ <span class="text-xs mt-1 opacity-75">This item could not be located</span>
343
+ </div>
344
+
345
+ <!-- Media content (image or video) -->
346
+ <div v-else class="relative w-full h-full">
347
+ <!-- Image (always rendered when src exists, fades in when loaded) -->
348
+ <img
349
+ v-if="mediaType === 'image' && imageSrc"
350
+ :src="imageSrc"
351
+ :class="[
352
+ 'w-full h-full object-cover transition-opacity duration-700 ease-in-out',
353
+ imageLoaded && showMedia ? 'opacity-100' : 'opacity-0'
354
+ ]"
355
+ style="position: absolute; top: 0; left: 0;"
356
+ loading="lazy"
357
+ decoding="async"
358
+ alt=""
359
+ @mouseenter="emitMouseEnter('image')"
360
+ @mouseleave="emitMouseLeave('image')"
361
+ />
362
+
363
+ <!-- Video (always rendered when src exists, fades in when loaded) -->
364
+ <video
365
+ v-if="mediaType === 'video' && videoSrc"
366
+ ref="videoEl"
367
+ :src="videoSrc"
368
+ :class="[
369
+ 'w-full h-full object-cover transition-opacity duration-700 ease-in-out',
370
+ videoLoaded && showMedia ? 'opacity-100' : 'opacity-0'
371
+ ]"
372
+ style="position: absolute; top: 0; left: 0;"
373
+ muted
374
+ loop
375
+ playsinline
376
+ :autoplay="isSwipeMode && props.isActive"
377
+ :controls="isSwipeMode"
378
+ @click.stop="onVideoTap"
379
+ @touchend.stop.prevent="onVideoTap"
380
+ @mouseenter="onVideoMouseEnter"
381
+ @mouseleave="onVideoMouseLeave"
382
+ @error="videoError = true"
383
+ />
384
+
385
+ <!-- Placeholder background while loading or if not loaded yet (fades out when media appears) -->
386
+ <div
387
+ v-if="!imageLoaded && !videoLoaded && !imageError && !videoError"
388
+ :class="[
389
+ 'absolute inset-0 bg-slate-100 flex items-center justify-center transition-opacity duration-500',
390
+ showMedia ? 'opacity-0 pointer-events-none' : 'opacity-100'
391
+ ]"
392
+ >
393
+ <!-- Media type indicator badge - shown BEFORE preloading starts -->
394
+ <div class="w-12 h-12 rounded-full bg-white/80 backdrop-blur-sm flex items-center justify-center shadow-sm">
395
+ <i :class="mediaType === 'video' ? 'fas fa-video text-xl text-slate-400' : 'fas fa-image text-xl text-slate-400'"></i>
396
+ </div>
397
+ </div>
398
+
399
+ <!-- Spinner (only shown when loading) -->
400
+ <div
401
+ v-if="isLoading"
402
+ class="absolute bottom-2 left-1/2 transform -translate-x-1/2 flex items-center justify-center z-10"
403
+ >
404
+ <div class="bg-white/90 backdrop-blur-sm rounded-full px-3 py-1.5 shadow-sm">
405
+ <div class="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-500"></div>
406
+ </div>
407
+ </div>
408
+
409
+ <!-- Error state -->
410
+ <div
411
+ v-if="(mediaType === 'image' && imageError) || (mediaType === 'video' && videoError)"
412
+ class="absolute inset-0 flex flex-col items-center justify-center bg-slate-50 text-slate-400 text-sm p-4 text-center"
413
+ >
414
+ <i :class="mediaType === 'video' ? 'fas fa-video text-2xl mb-2 opacity-50' : 'fas fa-image text-2xl mb-2 opacity-50'"></i>
415
+ <span>Failed to load {{ mediaType }}</span>
416
+ </div>
417
+ </div>
418
+ </div>
419
+ </slot>
420
+ </div>
421
+
422
+ <!-- Footer section (gutter bottom) -->
423
+ <div
424
+ v-if="footerHeight > 0"
425
+ class="relative z-10"
426
+ :style="{ height: `${footerHeight}px` }"
427
+ >
428
+ <slot name="footer" :item="item" :remove="remove" :imageLoaded="imageLoaded" :imageError="imageError" :videoLoaded="videoLoaded" :videoError="videoError" :showNotFound="showNotFound" :isLoading="isLoading" :mediaType="mediaType" />
429
+ </div>
430
+ </div>
431
+ </template>