@wyxos/vibe 1.6.29 → 2.0.2

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.
Files changed (43) hide show
  1. package/README.md +29 -287
  2. package/lib/index.cjs +1 -0
  3. package/lib/index.js +795 -1791
  4. package/lib/logo-dark.svg +36 -36
  5. package/lib/logo-light.svg +29 -29
  6. package/lib/logo.svg +32 -32
  7. package/lib/manifest.json +1 -1
  8. package/package.json +82 -96
  9. package/LICENSE +0 -21
  10. package/lib/vibe.css +0 -1
  11. package/lib/vite.svg +0 -1
  12. package/src/App.vue +0 -35
  13. package/src/Masonry.vue +0 -1030
  14. package/src/archive/App.vue +0 -96
  15. package/src/archive/InfiniteMansonry.spec.ts +0 -10
  16. package/src/archive/InfiniteMasonry.vue +0 -218
  17. package/src/assets/vue.svg +0 -1
  18. package/src/calculateLayout.ts +0 -194
  19. package/src/components/CodeTabs.vue +0 -158
  20. package/src/components/MasonryItem.vue +0 -499
  21. package/src/components/examples/BasicExample.vue +0 -46
  22. package/src/components/examples/CustomItemExample.vue +0 -87
  23. package/src/components/examples/HeaderFooterExample.vue +0 -79
  24. package/src/components/examples/ManualInitExample.vue +0 -78
  25. package/src/components/examples/SwipeModeExample.vue +0 -40
  26. package/src/createMasonryTransitions.ts +0 -176
  27. package/src/main.ts +0 -6
  28. package/src/masonryUtils.ts +0 -96
  29. package/src/pages.json +0 -36402
  30. package/src/router/index.ts +0 -20
  31. package/src/style.css +0 -32
  32. package/src/types.ts +0 -101
  33. package/src/useMasonryDimensions.ts +0 -59
  34. package/src/useMasonryItems.ts +0 -231
  35. package/src/useMasonryLayout.ts +0 -164
  36. package/src/useMasonryPagination.ts +0 -539
  37. package/src/useMasonryScroll.ts +0 -61
  38. package/src/useMasonryVirtualization.ts +0 -140
  39. package/src/useSwipeMode.ts +0 -233
  40. package/src/utils/errorHandler.ts +0 -8
  41. package/src/views/Examples.vue +0 -323
  42. package/src/views/Home.vue +0 -321
  43. package/toggle-link.mjs +0 -92
@@ -1,499 +0,0 @@
1
- <script setup lang="ts">
2
- import { ref, onMounted, onUnmounted, watch, computed, 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
- // Preload threshold: when to start preloading (0.0 to 1.0, default 1.0 = fully in view)
15
- preloadThreshold?: number;
16
- }>(), {
17
- // Auto-read from item if not explicitly provided
18
- type: undefined,
19
- notFound: undefined,
20
- headerHeight: 0,
21
- footerHeight: 0,
22
- isActive: false,
23
- inSwipeMode: false,
24
- preloadThreshold: 1.0
25
- });
26
-
27
- const emit = defineEmits<{
28
- (e: 'preload:success', payload: { item: any; type: 'image' | 'video'; src: string }): void
29
- (e: 'preload:error', payload: { item: any; type: 'image' | 'video'; src: string; error?: unknown }): void
30
- (e: 'mouse-enter', payload: { item: any; type: 'image' | 'video' }): void
31
- (e: 'mouse-leave', payload: { item: any; type: 'image' | 'video' }): void
32
- (e: 'in-view', payload: { item: any; type: 'image' | 'video' }): void
33
- (e: 'in-view-and-loaded', payload: { item: any; type: 'image' | 'video'; src: string }): void
34
- }>()
35
-
36
- const imageLoaded = ref(false);
37
- const imageError = ref(false);
38
- const imageSrc = ref<string | null>(null);
39
- const videoLoaded = ref(false);
40
- const videoError = ref(false);
41
- const videoSrc = ref<string | null>(null);
42
- const isInView = ref(false);
43
- const isFullyInView = ref(false); // Track when fully visible (for in-view event)
44
- const isFullyInViewAndLoaded = ref(false); // Track when both fully in view AND loaded (for in-view-and-loaded event)
45
- const isLoading = ref(false);
46
- const showMedia = ref(false); // Controls fade-in animation
47
- const containerRef = ref<HTMLElement | null>(null);
48
- const videoEl = ref<HTMLVideoElement | null>(null);
49
- let intersectionObserver: IntersectionObserver | null = null;
50
-
51
- // Auto-read from props or item object, default to 'image'
52
- const mediaType = computed(() => props.type ?? props.item?.type ?? 'image');
53
- const showNotFound = computed(() => props.notFound ?? props.item?.notFound ?? false);
54
- const isSwipeMode = computed(() => !!props.inSwipeMode);
55
-
56
- /**
57
- * Check if item is both fully in view AND loaded, then emit in-view-and-loaded event.
58
- * This event only fires once when both conditions are met.
59
- */
60
- function checkAndEmitInViewAndLoaded(type: 'image' | 'video', src: string): void {
61
- // Only emit if:
62
- // 1. Item is fully in view
63
- // 2. Media is loaded (imageLoaded for images, videoLoaded for videos)
64
- // 3. We haven't already emitted this event
65
- const isLoaded = type === 'image' ? imageLoaded.value : videoLoaded.value;
66
-
67
- if (isFullyInView.value && isLoaded && !isFullyInViewAndLoaded.value) {
68
- isFullyInViewAndLoaded.value = true;
69
- emit('in-view-and-loaded', { item: props.item, type, src });
70
- }
71
- }
72
-
73
- function emitMouseEnter(type: 'image' | 'video'): void {
74
- emit('mouse-enter', { item: props.item, type });
75
- }
76
-
77
- function emitMouseLeave(type: 'image' | 'video'): void {
78
- emit('mouse-leave', { item: props.item, type });
79
- }
80
-
81
- function onVideoTap(event: Event): void {
82
- // In swipe mode, let native controls handle play/pause
83
- if (isSwipeMode.value) return;
84
-
85
- const el = event.target as HTMLVideoElement | null;
86
- if (!el) return;
87
- if (el.paused) {
88
- void el.play();
89
- } else {
90
- el.pause();
91
- }
92
- }
93
-
94
- function onVideoMouseEnter(event: Event): void {
95
- const el = event.target as HTMLVideoElement | null;
96
- if (!el) return;
97
-
98
- // In swipe mode, don't auto-play on hover
99
- if (!isSwipeMode.value) {
100
- void el.play();
101
- }
102
- emitMouseEnter('video');
103
- }
104
-
105
- function onVideoMouseLeave(event: Event): void {
106
- const el = event.target as HTMLVideoElement | null;
107
- if (!el) return;
108
-
109
- // In swipe mode, don't auto-pause on hover leave
110
- if (!isSwipeMode.value) {
111
- el.pause();
112
- }
113
- emitMouseLeave('video');
114
- }
115
-
116
- function preloadImage(src: string): Promise<void> {
117
- return new Promise((resolve, reject) => {
118
- if (!src) {
119
- const err = new Error('No image source provided');
120
- emit('preload:error', { item: props.item, type: 'image', src, error: err });
121
- reject(err);
122
- return;
123
- }
124
-
125
- const img = new Image();
126
- const startTime = Date.now();
127
- const minLoadTime = 300; // Minimum time to show spinner (300ms)
128
-
129
- img.onload = () => {
130
- const elapsed = Date.now() - startTime;
131
- const remaining = Math.max(0, minLoadTime - elapsed);
132
-
133
- // Ensure spinner shows for at least minLoadTime
134
- setTimeout(async () => {
135
- imageLoaded.value = true;
136
- imageError.value = false;
137
- isLoading.value = false;
138
- // Wait for Vue to update DOM, then trigger fade-in
139
- await nextTick();
140
- await new Promise(resolve => setTimeout(resolve, 100));
141
- showMedia.value = true;
142
- emit('preload:success', { item: props.item, type: 'image', src });
143
-
144
- // Check if we should emit in-view-and-loaded event
145
- checkAndEmitInViewAndLoaded('image', src);
146
-
147
- resolve();
148
- }, remaining);
149
- };
150
- img.onerror = () => {
151
- imageError.value = true;
152
- imageLoaded.value = false;
153
- isLoading.value = false;
154
- const err = new Error('Failed to load image');
155
- emit('preload:error', { item: props.item, type: 'image', src, error: err });
156
- reject(err);
157
- };
158
- img.src = src;
159
- });
160
- }
161
-
162
- function preloadVideo(src: string): Promise<void> {
163
- return new Promise((resolve, reject) => {
164
- if (!src) {
165
- const err = new Error('No video source provided');
166
- emit('preload:error', { item: props.item, type: 'video', src, error: err });
167
- reject(err);
168
- return;
169
- }
170
-
171
- const video = document.createElement('video');
172
- const startTime = Date.now();
173
- const minLoadTime = 300;
174
-
175
- video.preload = 'metadata';
176
- video.muted = true; // Muted for autoplay compatibility
177
-
178
- video.onloadedmetadata = () => {
179
- const elapsed = Date.now() - startTime;
180
- const remaining = Math.max(0, minLoadTime - elapsed);
181
-
182
- setTimeout(async () => {
183
- videoLoaded.value = true;
184
- videoError.value = false;
185
- isLoading.value = false;
186
- // Wait for Vue to update DOM, then trigger fade-in
187
- await nextTick();
188
- await new Promise(resolve => setTimeout(resolve, 100));
189
- showMedia.value = true;
190
- emit('preload:success', { item: props.item, type: 'video', src });
191
-
192
- // Check if we should emit in-view-and-loaded event
193
- checkAndEmitInViewAndLoaded('video', src);
194
-
195
- resolve();
196
- }, remaining);
197
- };
198
-
199
- video.onerror = () => {
200
- videoError.value = true;
201
- videoLoaded.value = false;
202
- isLoading.value = false;
203
- const err = new Error('Failed to load video');
204
- emit('preload:error', { item: props.item, type: 'video', src, error: err });
205
- reject(err);
206
- };
207
-
208
- video.src = src;
209
- });
210
- }
211
-
212
- async function startPreloading() {
213
- // Skip preloading if:
214
- // - not in view
215
- // - already loading
216
- // - already loaded (prevent re-triggering)
217
- // - notFound is true
218
- if (!isInView.value || isLoading.value || showNotFound.value) {
219
- return;
220
- }
221
-
222
- // Don't start preloading if media is already loaded
223
- if ((mediaType.value === 'video' && videoLoaded.value) ||
224
- (mediaType.value === 'image' && imageLoaded.value)) {
225
- return;
226
- }
227
-
228
- const src = props.item?.src;
229
- if (!src) return;
230
-
231
- isLoading.value = true;
232
- showMedia.value = false; // Reset fade-in state
233
-
234
- if (mediaType.value === 'video') {
235
- videoSrc.value = src;
236
- videoLoaded.value = false;
237
- videoError.value = false;
238
- try {
239
- await preloadVideo(src);
240
- } catch {
241
- // Error handled by videoError state
242
- }
243
- } else {
244
- imageSrc.value = src;
245
- imageLoaded.value = false;
246
- imageError.value = false;
247
- try {
248
- await preloadImage(src);
249
- } catch {
250
- // Error handled by imageError state
251
- }
252
- }
253
- }
254
-
255
- onMounted(async () => {
256
- // Set up Intersection Observer to detect when item comes into view
257
- // We set it up even for notFound items, but skip preloading
258
- if (!containerRef.value) return;
259
-
260
- // Calculate thresholds: we need both preloadThreshold and 1.0 (fully in view)
261
- const thresholds = [props.preloadThreshold, 1.0].filter((t, i, arr) => arr.indexOf(t) === i).sort((a, b) => a - b);
262
-
263
- // Use Intersection Observer to detect when item comes into view
264
- // Start preloading at preloadThreshold, emit in-view when fully visible (1.0)
265
- intersectionObserver = new IntersectionObserver(
266
- (entries) => {
267
- entries.forEach((entry) => {
268
- const ratio = entry.intersectionRatio;
269
- const isFullyVisible = ratio >= 1.0;
270
- const shouldPreload = ratio >= props.preloadThreshold;
271
-
272
- // Emit in-view event when fully visible (only once)
273
- if (isFullyVisible && !isFullyInView.value) {
274
- isFullyInView.value = true;
275
- emit('in-view', { item: props.item, type: mediaType.value });
276
-
277
- // Check if media is already loaded (item came into view after loading)
278
- const currentSrc = mediaType.value === 'image' ? imageSrc.value : videoSrc.value;
279
- const isLoaded = mediaType.value === 'image' ? imageLoaded.value : videoLoaded.value;
280
- if (currentSrc && isLoaded) {
281
- checkAndEmitInViewAndLoaded(mediaType.value, currentSrc);
282
- }
283
- }
284
-
285
- // Start preloading when threshold is reached
286
- if (shouldPreload && !isInView.value) {
287
- isInView.value = true;
288
- startPreloading();
289
- } else if (!entry.isIntersecting) {
290
- // Reset isInView when item leaves viewport (optional, for re-loading if needed)
291
- // But we don't reset here to prevent re-triggering on scroll
292
- }
293
- });
294
- },
295
- {
296
- // Trigger at both preloadThreshold and 1.0 (fully in view)
297
- threshold: thresholds
298
- }
299
- );
300
-
301
- intersectionObserver.observe(containerRef.value);
302
-
303
- // Check initial visibility for items already in viewport when mounted
304
- // IntersectionObserver should fire immediately, but we check manually as a fallback
305
- // Wait for Vue to finish rendering, then for layout to complete, then check visibility
306
- await nextTick();
307
-
308
- // Double nextTick ensures layout is complete (especially for masonry layouts)
309
- await nextTick();
310
- await nextTick();
311
- checkInitialVisibility();
312
-
313
- // Also check after a small delay to catch items that become visible after masonry layout completes
314
- setTimeout(() => {
315
- checkInitialVisibility();
316
- }, 100);
317
-
318
- function checkInitialVisibility(): void {
319
- if (!containerRef.value || isFullyInView.value) return;
320
-
321
- // Use IntersectionObserver's logic: check if entry would have intersectionRatio >= 1.0
322
- // We manually calculate this to ensure we catch items already in view
323
- const rect = containerRef.value.getBoundingClientRect();
324
- const viewportHeight = window.innerHeight;
325
- const viewportWidth = window.innerWidth;
326
-
327
- // Check if item is fully visible (intersectionRatio >= 1.0)
328
- // Item is fully visible if it's completely within the viewport
329
- const isFullyVisible = rect.top >= 0
330
- && rect.bottom <= viewportHeight
331
- && rect.left >= 0
332
- && rect.right <= viewportWidth
333
- && rect.height > 0
334
- && rect.width > 0;
335
-
336
- if (isFullyVisible) {
337
- isFullyInView.value = true;
338
- emit('in-view', { item: props.item, type: mediaType.value });
339
-
340
- // Check if media is already loaded (item was already in view when mounted)
341
- const currentSrc = mediaType.value === 'image' ? imageSrc.value : videoSrc.value;
342
- const isLoaded = mediaType.value === 'image' ? imageLoaded.value : videoLoaded.value;
343
- if (currentSrc && isLoaded) {
344
- checkAndEmitInViewAndLoaded(mediaType.value, currentSrc);
345
- }
346
- }
347
- }
348
- });
349
-
350
- onUnmounted(() => {
351
- // Clean up Intersection Observer to prevent memory leaks
352
- if (intersectionObserver) {
353
- intersectionObserver.disconnect();
354
- intersectionObserver = null;
355
- }
356
- });
357
-
358
- watch(
359
- () => props.item?.src,
360
- async (newSrc) => {
361
- if (!newSrc || showNotFound.value) return;
362
-
363
- // Reset states when src changes
364
- if (mediaType.value === 'video') {
365
- if (newSrc !== videoSrc.value) {
366
- videoLoaded.value = false;
367
- videoError.value = false;
368
- videoSrc.value = newSrc;
369
- if (isInView.value) {
370
- isLoading.value = true;
371
- try {
372
- await preloadVideo(newSrc);
373
- } catch {
374
- // Error handled by videoError state
375
- }
376
- }
377
- }
378
- } else {
379
- if (newSrc !== imageSrc.value) {
380
- imageLoaded.value = false;
381
- imageError.value = false;
382
- imageSrc.value = newSrc;
383
- if (isInView.value) {
384
- isLoading.value = true;
385
- try {
386
- await preloadImage(newSrc);
387
- } catch {
388
- // Error handled by imageError state
389
- }
390
- }
391
- }
392
- }
393
- }
394
- );
395
-
396
- // Auto-play / pause in swipe mode when active item changes
397
- watch(
398
- () => props.isActive,
399
- (active) => {
400
- if (!isSwipeMode.value || !videoEl.value) return;
401
- if (active) {
402
- void videoEl.value.play();
403
- } else {
404
- videoEl.value.pause();
405
- }
406
- }
407
- );
408
-
409
- // Note: We don't watch isInView here because startPreloading() is already called
410
- // from the IntersectionObserver callback, and we want to prevent re-triggering
411
- </script>
412
-
413
- <template>
414
- <div ref="containerRef" class="relative w-full h-full flex flex-col">
415
- <!-- Header section (gutter top) -->
416
- <div v-if="headerHeight > 0" class="relative z-10" :style="{ height: `${headerHeight}px` }">
417
- <slot name="header" :item="item" :remove="remove" :imageLoaded="imageLoaded" :imageError="imageError"
418
- :videoLoaded="videoLoaded" :videoError="videoError" :showNotFound="showNotFound" :isLoading="isLoading"
419
- :mediaType="mediaType" :isFullyInView="isFullyInView" />
420
- </div>
421
-
422
- <!-- Body section (main content) -->
423
- <div class="flex-1 relative min-h-0">
424
- <!-- Custom slot content (replaces default if provided) -->
425
- <slot :item="item" :remove="remove" :imageLoaded="imageLoaded" :imageError="imageError" :videoLoaded="videoLoaded"
426
- :videoError="videoError" :showNotFound="showNotFound" :isLoading="isLoading" :mediaType="mediaType"
427
- :imageSrc="imageSrc" :videoSrc="videoSrc" :showMedia="showMedia" :isFullyInView="isFullyInView">
428
- <!-- Default content when no slot is provided -->
429
- <div class="w-full h-full rounded-xl overflow-hidden shadow-sm transition-all duration-300 bg-white relative">
430
- <!-- Not Found state -->
431
- <div v-if="showNotFound"
432
- class="absolute inset-0 flex flex-col items-center justify-center bg-slate-100 text-slate-400 text-sm p-4 text-center">
433
- <i class="fas fa-search text-3xl mb-3 opacity-50"></i>
434
- <span class="font-medium">Not Found</span>
435
- <span class="text-xs mt-1 opacity-75">This item could not be located</span>
436
- </div>
437
-
438
- <!-- Media content (image or video) -->
439
- <div v-else class="relative w-full h-full">
440
- <!-- Image (always rendered when src exists, fades in when loaded) -->
441
- <img v-if="mediaType === 'image' && imageSrc" :src="imageSrc" :class="[
442
- 'w-full h-full object-cover transition-opacity duration-700 ease-in-out',
443
- imageLoaded && showMedia ? 'opacity-100' : 'opacity-0'
444
- ]" style="position: absolute; top: 0; left: 0;" loading="lazy" decoding="async" alt=""
445
- @mouseenter="emitMouseEnter('image')" @mouseleave="emitMouseLeave('image')" />
446
-
447
- <!-- Video (always rendered when src exists, fades in when loaded) -->
448
- <video v-if="mediaType === 'video' && videoSrc" ref="videoEl" :src="videoSrc" :class="[
449
- 'w-full h-full object-cover transition-opacity duration-700 ease-in-out',
450
- videoLoaded && showMedia ? 'opacity-100' : 'opacity-0'
451
- ]" style="position: absolute; top: 0; left: 0;" muted loop playsinline
452
- :autoplay="isSwipeMode && props.isActive" :controls="isSwipeMode" @click.stop="onVideoTap"
453
- @touchend.stop.prevent="onVideoTap" @mouseenter="onVideoMouseEnter" @mouseleave="onVideoMouseLeave"
454
- @error="videoError = true" />
455
-
456
- <!-- Placeholder background while loading or if not loaded yet (fades out when media appears) -->
457
- <div v-if="!imageLoaded && !videoLoaded && !imageError && !videoError" :class="[
458
- 'absolute inset-0 bg-slate-100 flex items-center justify-center transition-opacity duration-500',
459
- showMedia ? 'opacity-0 pointer-events-none' : 'opacity-100'
460
- ]">
461
- <!-- Media type indicator badge - shown BEFORE preloading starts -->
462
- <div
463
- class="w-12 h-12 rounded-full bg-white/80 backdrop-blur-sm flex items-center justify-center shadow-sm">
464
- <!-- Allow custom icon via slot, fallback to default -->
465
- <slot name="placeholder-icon" :mediaType="mediaType">
466
- <i
467
- :class="mediaType === 'video' ? 'fas fa-video text-xl text-slate-400' : 'fas fa-image text-xl text-slate-400'"></i>
468
- </slot>
469
- </div>
470
- </div>
471
-
472
- <!-- Spinner (only shown when loading) -->
473
- <div v-if="isLoading"
474
- class="absolute bottom-2 left-1/2 transform -translate-x-1/2 flex items-center justify-center z-10">
475
- <div class="bg-white/90 backdrop-blur-sm rounded-full px-3 py-1.5 shadow-sm">
476
- <div class="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-500"></div>
477
- </div>
478
- </div>
479
-
480
- <!-- Error state -->
481
- <div v-if="(mediaType === 'image' && imageError) || (mediaType === 'video' && videoError)"
482
- class="absolute inset-0 flex flex-col items-center justify-center bg-slate-50 text-slate-400 text-sm p-4 text-center">
483
- <i
484
- :class="mediaType === 'video' ? 'fas fa-video text-2xl mb-2 opacity-50' : 'fas fa-image text-2xl mb-2 opacity-50'"></i>
485
- <span>Failed to load {{ mediaType }}</span>
486
- </div>
487
- </div>
488
- </div>
489
- </slot>
490
- </div>
491
-
492
- <!-- Footer section (gutter bottom) -->
493
- <div v-if="footerHeight > 0" class="relative z-10" :style="{ height: `${footerHeight}px` }">
494
- <slot name="footer" :item="item" :remove="remove" :imageLoaded="imageLoaded" :imageError="imageError"
495
- :videoLoaded="videoLoaded" :videoError="videoError" :showNotFound="showNotFound" :isLoading="isLoading"
496
- :mediaType="mediaType" :isFullyInView="isFullyInView" />
497
- </div>
498
- </div>
499
- </template>
@@ -1,46 +0,0 @@
1
- <template>
2
- <Masonry
3
- v-model:items="items"
4
- :get-page="getPage"
5
- :load-at-page="1"
6
- :layout="layout"
7
- init="auto"
8
- />
9
- </template>
10
-
11
- <script setup lang="ts">
12
- import { ref } from 'vue'
13
- import Masonry from '../../Masonry.vue'
14
- import fixture from '../../pages.json'
15
- import type { MasonryItem, GetPageResult } from '../../types'
16
-
17
- const items = ref<MasonryItem[]>([])
18
-
19
- const layout = {
20
- sizes: { base: 1, sm: 2, md: 3, lg: 4 },
21
- gutterX: 10,
22
- gutterY: 10
23
- }
24
-
25
- const getPage = async (page: number): Promise<GetPageResult> => {
26
- return new Promise((resolve) => {
27
- setTimeout(() => {
28
- const pageData = (fixture as any[])[page - 1] as { items: MasonryItem[] } | undefined
29
-
30
- if (!pageData) {
31
- resolve({
32
- items: [],
33
- nextPage: null
34
- })
35
- return
36
- }
37
-
38
- resolve({
39
- items: pageData.items,
40
- nextPage: page < (fixture as any[]).length ? page + 1 : null
41
- })
42
- }, 300)
43
- })
44
- }
45
- </script>
46
-
@@ -1,87 +0,0 @@
1
- <template>
2
- <Masonry
3
- v-model:items="items"
4
- :get-page="getPage"
5
- :load-at-page="1"
6
- :layout="layout"
7
- init="auto"
8
- >
9
- <template #item="{ item, remove }">
10
- <div class="custom-card">
11
- <img v-if="item.src" :src="item.src" :alt="item.title || 'Item'" />
12
- <div class="overlay">
13
- <h3 class="text-white font-semibold text-sm mb-2">{{ item.title || 'Untitled' }}</h3>
14
- <button
15
- @click="remove"
16
- class="px-3 py-1 bg-white/20 hover:bg-white/30 border border-white/30 rounded text-white text-xs transition-colors"
17
- >
18
- Remove
19
- </button>
20
- </div>
21
- </div>
22
- </template>
23
- </Masonry>
24
- </template>
25
-
26
- <script setup lang="ts">
27
- import { ref } from 'vue'
28
- import Masonry from '../../Masonry.vue'
29
- import fixture from '../../pages.json'
30
- import type { MasonryItem, GetPageResult } from '../../types'
31
-
32
- const items = ref<MasonryItem[]>([])
33
-
34
- const layout = {
35
- sizes: { base: 1, sm: 2, md: 3, lg: 4 },
36
- gutterX: 10,
37
- gutterY: 10
38
- }
39
-
40
- const getPage = async (page: number): Promise<GetPageResult> => {
41
- return new Promise((resolve) => {
42
- setTimeout(() => {
43
- const pageData = (fixture as any[])[page - 1] as { items: MasonryItem[] } | undefined
44
-
45
- if (!pageData) {
46
- resolve({
47
- items: [],
48
- nextPage: null
49
- })
50
- return
51
- }
52
-
53
- resolve({
54
- items: pageData.items,
55
- nextPage: page < (fixture as any[]).length ? page + 1 : null
56
- })
57
- }, 300)
58
- })
59
- }
60
- </script>
61
-
62
- <style scoped>
63
- .custom-card {
64
- position: relative;
65
- width: 100%;
66
- height: 100%;
67
- border-radius: 8px;
68
- overflow: hidden;
69
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
70
- }
71
-
72
- .custom-card img {
73
- width: 100%;
74
- height: 100%;
75
- object-fit: cover;
76
- }
77
-
78
- .custom-card .overlay {
79
- position: absolute;
80
- bottom: 0;
81
- left: 0;
82
- right: 0;
83
- background: linear-gradient(to top, rgba(0,0,0,0.8), transparent);
84
- padding: 16px;
85
- }
86
- </style>
87
-