@wyxos/vibe 1.6.6 → 1.6.7

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.
@@ -0,0 +1,36 @@
1
+ <svg width="120" height="120" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <defs>
3
+ <linearGradient id="vibeGrad" x1="0%" y1="0%" x2="100%" y2="100%">
4
+ <stop offset="0%" style="stop-color:#3b82f6;stop-opacity:1" />
5
+ <stop offset="100%" style="stop-color:#06b6d4;stop-opacity:1" />
6
+ </linearGradient>
7
+ <linearGradient id="vibeGradLight" x1="0%" y1="0%" x2="100%" y2="100%">
8
+ <stop offset="0%" style="stop-color:#60a5fa;stop-opacity:1" />
9
+ <stop offset="100%" style="stop-color:#22d3ee;stop-opacity:1" />
10
+ </linearGradient>
11
+ </defs>
12
+
13
+ <!-- Background -->
14
+ <rect width="120" height="120" rx="24" fill="url(#vibeGrad)"/>
15
+
16
+ <!-- Masonry blocks - representing the infinite block engine -->
17
+ <!-- Left column -->
18
+ <rect x="18" y="20" width="24" height="32" rx="5" fill="white" opacity="0.95"/>
19
+ <rect x="18" y="58" width="24" height="20" rx="5" fill="white" opacity="0.85"/>
20
+ <rect x="18" y="84" width="24" height="16" rx="5" fill="white" opacity="0.9"/>
21
+
22
+ <!-- Middle column -->
23
+ <rect x="48" y="28" width="24" height="22" rx="5" fill="white" opacity="0.9"/>
24
+ <rect x="48" y="56" width="24" height="36" rx="5" fill="white" opacity="0.95"/>
25
+ <rect x="48" y="98" width="24" height="14" rx="5" fill="white" opacity="0.8"/>
26
+
27
+ <!-- Right column -->
28
+ <rect x="78" y="22" width="24" height="38" rx="5" fill="white" opacity="0.85"/>
29
+ <rect x="78" y="66" width="24" height="24" rx="5" fill="white" opacity="0.9"/>
30
+ <rect x="78" y="96" width="24" height="16" rx="5" fill="white" opacity="0.85"/>
31
+
32
+ <!-- Infinite loop symbol (subtle overlay) -->
33
+ <path d="M 30 60 Q 40 50, 50 60 Q 60 70, 70 60" stroke="url(#vibeGradLight)" stroke-width="3" fill="none" opacity="0.4" stroke-linecap="round"/>
34
+ <path d="M 30 60 Q 40 70, 50 60 Q 60 50, 70 60" stroke="url(#vibeGradLight)" stroke-width="3" fill="none" opacity="0.4" stroke-linecap="round"/>
35
+ </svg>
36
+
@@ -0,0 +1,29 @@
1
+ <svg width="120" height="120" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <defs>
3
+ <linearGradient id="vibeGradLight" x1="0%" y1="0%" x2="100%" y2="100%">
4
+ <stop offset="0%" style="stop-color:#3b82f6;stop-opacity:1" />
5
+ <stop offset="100%" style="stop-color:#06b6d4;stop-opacity:1" />
6
+ </linearGradient>
7
+ </defs>
8
+
9
+ <!-- Masonry blocks - representing the infinite block engine -->
10
+ <!-- Left column -->
11
+ <rect x="18" y="20" width="24" height="32" rx="5" fill="url(#vibeGradLight)" opacity="0.95"/>
12
+ <rect x="18" y="58" width="24" height="20" rx="5" fill="url(#vibeGradLight)" opacity="0.85"/>
13
+ <rect x="18" y="84" width="24" height="16" rx="5" fill="url(#vibeGradLight)" opacity="0.9"/>
14
+
15
+ <!-- Middle column -->
16
+ <rect x="48" y="28" width="24" height="22" rx="5" fill="url(#vibeGradLight)" opacity="0.9"/>
17
+ <rect x="48" y="56" width="24" height="36" rx="5" fill="url(#vibeGradLight)" opacity="0.95"/>
18
+ <rect x="48" y="98" width="24" height="14" rx="5" fill="url(#vibeGradLight)" opacity="0.8"/>
19
+
20
+ <!-- Right column -->
21
+ <rect x="78" y="22" width="24" height="38" rx="5" fill="url(#vibeGradLight)" opacity="0.85"/>
22
+ <rect x="78" y="66" width="24" height="24" rx="5" fill="url(#vibeGradLight)" opacity="0.9"/>
23
+ <rect x="78" y="96" width="24" height="16" rx="5" fill="url(#vibeGradLight)" opacity="0.85"/>
24
+
25
+ <!-- Infinite loop symbol (subtle overlay) -->
26
+ <path d="M 30 60 Q 40 50, 50 60 Q 60 70, 70 60" stroke="url(#vibeGradLight)" stroke-width="3" fill="none" opacity="0.6" stroke-linecap="round"/>
27
+ <path d="M 30 60 Q 40 70, 50 60 Q 60 50, 70 60" stroke="url(#vibeGradLight)" stroke-width="3" fill="none" opacity="0.6" stroke-linecap="round"/>
28
+ </svg>
29
+
package/lib/logo.svg ADDED
@@ -0,0 +1,32 @@
1
+ <svg width="120" height="120" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <defs>
3
+ <linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
4
+ <stop offset="0%" style="stop-color:#3b82f6;stop-opacity:1" />
5
+ <stop offset="100%" style="stop-color:#06b6d4;stop-opacity:1" />
6
+ </linearGradient>
7
+ </defs>
8
+
9
+ <!-- Background circle -->
10
+ <circle cx="60" cy="60" r="58" fill="url(#grad1)" opacity="0.1"/>
11
+
12
+ <!-- Masonry blocks representing infinite layout -->
13
+ <!-- Column 1 -->
14
+ <rect x="20" y="15" width="22" height="28" rx="4" fill="url(#grad1)" opacity="0.9"/>
15
+ <rect x="20" y="48" width="22" height="18" rx="4" fill="url(#grad1)" opacity="0.7"/>
16
+ <rect x="20" y="71" width="22" height="34" rx="4" fill="url(#grad1)" opacity="0.8"/>
17
+
18
+ <!-- Column 2 -->
19
+ <rect x="49" y="25" width="22" height="20" rx="4" fill="url(#grad1)" opacity="0.8"/>
20
+ <rect x="49" y="50" width="22" height="32" rx="4" fill="url(#grad1)" opacity="0.9"/>
21
+ <rect x="49" y="87" width="22" height="18" rx="4" fill="url(#grad1)" opacity="0.6"/>
22
+
23
+ <!-- Column 3 -->
24
+ <rect x="78" y="18" width="22" height="35" rx="4" fill="url(#grad1)" opacity="0.7"/>
25
+ <rect x="78" y="58" width="22" height="22" rx="4" fill="url(#grad1)" opacity="0.9"/>
26
+ <rect x="78" y="85" width="22" height="20" rx="4" fill="url(#grad1)" opacity="0.8"/>
27
+
28
+ <!-- Infinite symbol overlay (subtle) -->
29
+ <path d="M 35 60 Q 45 50, 55 60 Q 65 70, 75 60" stroke="url(#grad1)" stroke-width="2.5" fill="none" opacity="0.6" stroke-linecap="round"/>
30
+ <path d="M 35 60 Q 45 70, 55 60 Q 65 50, 75 60" stroke="url(#grad1)" stroke-width="2.5" fill="none" opacity="0.6" stroke-linecap="round"/>
31
+ </svg>
32
+
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "VIBE - Vue Infinite Block Engine",
3
+ "short_name": "VIBE",
4
+ "description": "A high-performance, responsive masonry layout engine for Vue 3",
5
+ "start_url": "/",
6
+ "display": "standalone",
7
+ "background_color": "#f8fafc",
8
+ "theme_color": "#3b82f6",
9
+ "icons": [
10
+ {
11
+ "src": "favicon-16x16.png",
12
+ "sizes": "16x16",
13
+ "type": "image/png"
14
+ },
15
+ {
16
+ "src": "favicon-32x32.png",
17
+ "sizes": "32x32",
18
+ "type": "image/png"
19
+ },
20
+ {
21
+ "src": "favicon-48x48.png",
22
+ "sizes": "48x48",
23
+ "type": "image/png"
24
+ },
25
+ {
26
+ "src": "favicon-64x64.png",
27
+ "sizes": "64x64",
28
+ "type": "image/png"
29
+ },
30
+ {
31
+ "src": "favicon-128x128.png",
32
+ "sizes": "128x128",
33
+ "type": "image/png"
34
+ },
35
+ {
36
+ "src": "favicon-256x256.png",
37
+ "sizes": "256x256",
38
+ "type": "image/png"
39
+ }
40
+ ]
41
+ }
package/lib/vibe.css CHANGED
@@ -1 +1 @@
1
- .masonry-container[data-v-08b0e6d9]{overflow-anchor:none}.masonry-item[data-v-08b0e6d9]{will-change:transform,opacity;contain:layout paint;transition:transform var(--masonry-duration, .45s) var(--masonry-ease, cubic-bezier(.22, .61, .36, 1)),opacity var(--masonry-leave-duration, .16s) ease-out var(--masonry-opacity-delay, 0ms);backface-visibility:hidden}.masonry-move[data-v-08b0e6d9]{transition:transform var(--masonry-duration, .45s) var(--masonry-ease, cubic-bezier(.22, .61, .36, 1))}@media (prefers-reduced-motion: reduce){.masonry-container:not(.force-motion) .masonry-item[data-v-08b0e6d9],.masonry-container:not(.force-motion) .masonry-move[data-v-08b0e6d9]{transition-duration:1ms!important}}
1
+ .masonry-container[data-v-110c3294]{overflow-anchor:none}.masonry-item[data-v-110c3294]{will-change:transform,opacity;contain:layout paint;transition:transform var(--masonry-duration, .45s) var(--masonry-ease, cubic-bezier(.22, .61, .36, 1)),opacity var(--masonry-leave-duration, .16s) ease-out var(--masonry-opacity-delay, 0ms);backface-visibility:hidden}.masonry-move[data-v-110c3294]{transition:transform var(--masonry-duration, .45s) var(--masonry-ease, cubic-bezier(.22, .61, .36, 1))}@media (prefers-reduced-motion: reduce){.masonry-container:not(.force-motion) .masonry-item[data-v-110c3294],.masonry-container:not(.force-motion) .masonry-move[data-v-110c3294]{transition-duration:1ms!important}}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wyxos/vibe",
3
- "version": "1.6.6",
3
+ "version": "1.6.7",
4
4
  "main": "lib/index.js",
5
5
  "module": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
@@ -38,12 +38,14 @@
38
38
  "dependencies": {
39
39
  "lodash": "^4.17.21",
40
40
  "lodash-es": "^4.17.21",
41
- "vue": "^3.0.0"
41
+ "vue": "^3.0.0",
42
+ "vue-router": "^4.6.3"
42
43
  },
43
44
  "peerDependencies": {
44
45
  "vue": "^3.0.0"
45
46
  },
46
47
  "devDependencies": {
48
+ "@resvg/resvg-js": "^2.6.2",
47
49
  "@tailwindcss/vite": "^4.0.15",
48
50
  "@types/lodash-es": "^4.17.12",
49
51
  "@types/node": "^24.5.2",
@@ -52,6 +54,7 @@
52
54
  "chalk": "^5.3.0",
53
55
  "inquirer": "^10.1.8",
54
56
  "jsdom": "^26.0.0",
57
+ "sharp": "^0.34.5",
55
58
  "simple-git": "^3.27.0",
56
59
  "tailwindcss": "^4.0.15",
57
60
  "typescript": "^5.9.2",
package/src/App.vue CHANGED
@@ -1,164 +1,35 @@
1
- <script setup lang="ts">
2
- import Masonry from "./Masonry.vue";
3
- import { ref, reactive, computed } from "vue";
4
- import fixture from "./pages.json";
5
- import type { MasonryItem, GetPageResult } from "./types";
6
-
7
- const items = ref<MasonryItem[]>([]);
8
-
9
- const masonry = ref<InstanceType<typeof Masonry> | null>(null);
10
-
11
- const layoutParams = reactive({
12
- sizes: {
13
- base: 1,
14
- sm: 2,
15
- md: 3,
16
- lg: 4,
17
- xl: 5,
18
- '2xl': 10
19
- },
20
- header: 0,
21
- footer: 0
22
- });
23
-
24
- const layout = computed(() => ({
25
- sizes: { ...layoutParams.sizes },
26
- header: layoutParams.header,
27
- footer: layoutParams.footer
28
- }));
29
-
30
- const showLayoutControls = ref(false);
31
-
32
- const getPage = async (page: number): Promise<GetPageResult> => {
33
- return new Promise((resolve) => {
34
- setTimeout(() => {
35
- // Check if the page exists in the fixture
36
- const pageData = (fixture as any[])[page - 1] as { items: MasonryItem[] } | undefined;
37
-
38
- if (!pageData) {
39
- // Return empty items if page doesn't exist
40
- resolve({
41
- items: [],
42
- nextPage: null // null indicates no more pages
43
- });
44
- return;
45
- }
46
-
47
- const output: GetPageResult = {
48
- items: pageData.items,
49
- nextPage: page < (fixture as any[]).length ? page + 1 : null
50
- };
51
-
52
- resolve(output);
53
- }, 1000);
54
- });
55
- };
56
- </script>
57
1
  <template>
58
- <main class="flex flex-col h-screen overflow-hidden bg-slate-50 relative">
59
- <!-- Floating Header -->
60
- <header class="fixed top-4 left-0 right-0 z-20 w-full max-w-4xl mx-auto px-4 pointer-events-none">
61
- <div class="bg-white/80 backdrop-blur-md border border-white/20 shadow-lg rounded-2xl p-4 flex items-center justify-between transition-all duration-300 hover:shadow-xl pointer-events-auto">
62
- <div class="flex items-center gap-3">
63
- <div class="w-10 h-10 bg-gradient-to-br from-blue-500 to-cyan-500 rounded-xl flex items-center justify-center shadow-inner">
64
- <i class="fas fa-layer-group text-white text-lg"></i>
65
- </div>
66
- <div>
67
- <h1 class="text-lg font-bold text-slate-800 leading-tight">VIBE</h1>
68
- <p class="text-xs text-slate-500 font-medium">Vue Infinite Block Engine</p>
2
+ <div id="app">
3
+ <nav class="fixed top-0 left-0 right-0 z-30 bg-white/90 backdrop-blur-md border-b border-slate-200 shadow-sm">
4
+ <div class="max-w-7xl mx-auto px-4 py-3">
5
+ <div class="flex items-center justify-between">
6
+ <router-link to="/" class="flex items-center gap-2 text-lg font-bold text-slate-800 hover:text-blue-600 transition-colors">
7
+ <img src="/logo-light.svg" alt="VIBE" class="w-8 h-8" />
8
+ <span>VIBE</span>
9
+ </router-link>
10
+ <div class="flex items-center gap-4">
11
+ <router-link
12
+ to="/"
13
+ class="text-sm text-slate-600 hover:text-slate-800 transition-colors"
14
+ active-class="text-blue-600 font-medium"
15
+ >
16
+ Demo
17
+ </router-link>
18
+ <router-link
19
+ to="/examples"
20
+ class="text-sm text-slate-600 hover:text-slate-800 transition-colors"
21
+ active-class="text-blue-600 font-medium"
22
+ >
23
+ Examples
24
+ </router-link>
69
25
  </div>
70
26
  </div>
71
-
72
- <div class="flex items-center gap-4">
73
- <div v-if="masonry" class="hidden md:flex items-center gap-3 text-sm font-medium text-slate-600 bg-slate-100/50 px-3 py-1.5 rounded-lg border border-slate-200/50">
74
- <span class="flex items-center gap-1.5">
75
- <span class="w-2 h-2 rounded-full" :class="masonry.isLoading ? 'bg-amber-400 animate-pulse' : 'bg-emerald-400'"></span>
76
- {{ masonry.isLoading ? 'Loading...' : 'Ready' }}
77
- </span>
78
- <span class="w-px h-3 bg-slate-300"></span>
79
- <span>{{ items.length }} items</span>
80
- </div>
81
-
82
- <button
83
- @click="showLayoutControls = !showLayoutControls"
84
- class="p-2 text-slate-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
85
- :class="{ 'text-blue-600 bg-blue-50': showLayoutControls }"
86
- title="Layout Controls"
87
- >
88
- <i class="fas fa-sliders"></i>
89
- </button>
90
-
91
- <a href="https://github.com/wyxos/vibe" target="_blank" class="p-2 text-slate-400 hover:text-slate-800 transition-colors" title="View on GitHub">
92
- <i class="fab fa-github text-xl"></i>
93
- </a>
94
- </div>
95
27
  </div>
96
-
97
- <!-- Layout Controls Panel -->
98
- <transition
99
- enter-active-class="transition duration-200 ease-out"
100
- enter-from-class="transform -translate-y-2 opacity-0"
101
- enter-to-class="transform translate-y-0 opacity-100"
102
- leave-active-class="transition duration-150 ease-in"
103
- leave-from-class="transform translate-y-0 opacity-100"
104
- leave-to-class="transform -translate-y-2 opacity-0"
105
- >
106
- <div v-if="showLayoutControls" class="mt-2 bg-white/90 backdrop-blur-md border border-white/20 shadow-xl rounded-xl p-6 pointer-events-auto">
107
- <div class="grid grid-cols-1 md:grid-cols-2 gap-8">
108
- <!-- Column Settings -->
109
- <div>
110
- <h3 class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3">Column Configuration</h3>
111
- <div class="grid grid-cols-3 sm:grid-cols-6 gap-3">
112
- <div v-for="(val, key) in layoutParams.sizes" :key="key" class="flex flex-col gap-1.5">
113
- <label class="text-[10px] font-bold text-slate-500 uppercase text-center">{{ key }}</label>
114
- <input
115
- v-model.number="layoutParams.sizes[key]"
116
- type="number"
117
- min="1"
118
- class="w-full px-2 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"
119
- />
120
- </div>
121
- </div>
122
- </div>
123
-
124
- <!-- Spacing Settings -->
125
- <div>
126
- <h3 class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3">Spacing</h3>
127
- <div class="grid grid-cols-2 gap-4">
128
- <div class="flex flex-col gap-1.5">
129
- <label class="text-[10px] font-bold text-slate-500 uppercase">Header Offset</label>
130
- <div class="relative">
131
- <input
132
- v-model.number="layoutParams.header"
133
- type="number"
134
- min="0"
135
- class="w-full pl-3 pr-8 py-1.5 bg-slate-50 border border-slate-200 rounded-lg text-sm font-medium text-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all"
136
- />
137
- <span class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-400">px</span>
138
- </div>
139
- </div>
140
- <div class="flex flex-col gap-1.5">
141
- <label class="text-[10px] font-bold text-slate-500 uppercase">Footer Offset</label>
142
- <div class="relative">
143
- <input
144
- v-model.number="layoutParams.footer"
145
- type="number"
146
- min="0"
147
- class="w-full pl-3 pr-8 py-1.5 bg-slate-50 border border-slate-200 rounded-lg text-sm font-medium text-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all"
148
- />
149
- <span class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-400">px</span>
150
- </div>
151
- </div>
152
- </div>
153
- </div>
154
- </div>
155
- </div>
156
- </transition>
157
- </header>
158
-
159
- <!-- Main Content -->
160
- <div class="flex flex-1 overflow-hidden relative pt-24">
161
- <masonry v-model:items="items" :get-next-page="getPage" :load-at-page="1" :layout="layout" ref="masonry"></masonry>
162
- </div>
163
- </main>
28
+ </nav>
29
+ <router-view />
30
+ </div>
164
31
  </template>
32
+
33
+ <script setup lang="ts">
34
+ // Root App component with router
35
+ </script>
package/src/Masonry.vue CHANGED
@@ -96,7 +96,7 @@ const props = defineProps({
96
96
  autoRefreshOnEmpty: {
97
97
  type: Boolean,
98
98
  default: false
99
- }
99
+ },
100
100
  })
101
101
 
102
102
  const defaultLayout = {
@@ -753,7 +753,8 @@ onUnmounted(() => {
753
753
  class="absolute masonry-item"
754
754
  v-bind="getItemAttributes(item, i)"
755
755
  :style="{ paddingTop: `${layout.header}px`, paddingBottom: `${layout.footer}px` }">
756
- <slot name="item" v-bind="{item, remove}">
756
+ <!-- Use default slot if provided, otherwise use MasonryItem -->
757
+ <slot :item="item" :remove="remove">
757
758
  <MasonryItem :item="item" :remove="remove" />
758
759
  </slot>
759
760
  </div>
@@ -1,14 +1,26 @@
1
1
  <script setup lang="ts">
2
- import { ref, onMounted, watch } from 'vue';
2
+ import { ref, onMounted, 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
+ // Auto-read from props or item object, default to 'image'
22
+ const mediaType = computed(() => props.type ?? props.item?.type ?? 'image');
23
+ const showNotFound = computed(() => props.notFound ?? props.item?.notFound ?? false);
12
24
 
13
25
  function preloadImage(src: string): Promise<void> {
14
26
  return new Promise((resolve, reject) => {
@@ -41,14 +53,64 @@ function preloadImage(src: string): Promise<void> {
41
53
  });
42
54
  }
43
55
 
56
+ function preloadVideo(src: string): Promise<void> {
57
+ return new Promise((resolve, reject) => {
58
+ if (!src) {
59
+ reject(new Error('No video source provided'));
60
+ return;
61
+ }
62
+
63
+ const video = document.createElement('video');
64
+ const startTime = Date.now();
65
+ const minLoadTime = 300;
66
+
67
+ video.preload = 'metadata';
68
+ video.muted = true; // Muted for autoplay compatibility
69
+
70
+ video.onloadedmetadata = () => {
71
+ const elapsed = Date.now() - startTime;
72
+ const remaining = Math.max(0, minLoadTime - elapsed);
73
+
74
+ setTimeout(() => {
75
+ videoLoaded.value = true;
76
+ videoError.value = false;
77
+ resolve();
78
+ }, remaining);
79
+ };
80
+
81
+ video.onerror = () => {
82
+ videoError.value = true;
83
+ videoLoaded.value = false;
84
+ reject(new Error('Failed to load video'));
85
+ };
86
+
87
+ video.src = src;
88
+ });
89
+ }
90
+
44
91
  onMounted(async () => {
45
92
  // Debug: verify component is mounting
46
93
  console.log('[MasonryItem] Component mounted', props.item?.id);
47
94
 
95
+ // If notFound is true, skip preloading
96
+ if (showNotFound.value) {
97
+ return;
98
+ }
99
+
48
100
  const src = props.item?.src;
49
- if (src) {
101
+ if (!src) return;
102
+
103
+ if (mediaType.value === 'video') {
104
+ videoSrc.value = src;
105
+ videoLoaded.value = false;
106
+ videoError.value = false;
107
+ try {
108
+ await preloadVideo(src);
109
+ } catch {
110
+ // Error handled by videoError state
111
+ }
112
+ } else {
50
113
  imageSrc.value = src;
51
- // Reset state to ensure spinner shows
52
114
  imageLoaded.value = false;
53
115
  imageError.value = false;
54
116
  try {
@@ -62,29 +124,56 @@ onMounted(async () => {
62
124
  watch(
63
125
  () => props.item?.src,
64
126
  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
127
+ if (!newSrc || showNotFound.value) return;
128
+
129
+ if (mediaType.value === 'video') {
130
+ if (newSrc !== videoSrc.value) {
131
+ videoLoaded.value = false;
132
+ videoError.value = false;
133
+ videoSrc.value = newSrc;
134
+ try {
135
+ await preloadVideo(newSrc);
136
+ } catch {
137
+ // Error handled by videoError state
138
+ }
139
+ }
140
+ } else {
141
+ if (newSrc !== imageSrc.value) {
142
+ imageLoaded.value = false;
143
+ imageError.value = false;
144
+ imageSrc.value = newSrc;
145
+ try {
146
+ await preloadImage(newSrc);
147
+ } catch {
148
+ // Error handled by imageError state
149
+ }
73
150
  }
74
151
  }
75
152
  }
76
153
  );
154
+
155
+ // mediaType and showNotFound are now computed, so they automatically react to changes
77
156
  </script>
78
157
 
79
158
  <template>
80
159
  <div class="relative w-full h-full group">
81
160
  <!-- Custom slot content (replaces default if provided) -->
82
- <slot :item="item" :remove="remove" :imageLoaded="imageLoaded" :imageError="imageError">
161
+ <slot :item="item" :remove="remove" :imageLoaded="imageLoaded" :imageError="imageError" :videoLoaded="videoLoaded" :videoError="videoError" :showNotFound="showNotFound">
83
162
  <!-- Default content when no slot is provided -->
84
163
  <div class="w-full h-full rounded-xl overflow-hidden shadow-sm hover:shadow-md transition-all duration-300 bg-white relative">
164
+ <!-- Not Found state -->
165
+ <div
166
+ v-if="showNotFound"
167
+ class="absolute inset-0 flex flex-col items-center justify-center bg-slate-100 text-slate-400 text-sm p-4 text-center"
168
+ >
169
+ <i class="fas fa-search text-3xl mb-3 opacity-50"></i>
170
+ <span class="font-medium">Not Found</span>
171
+ <span class="text-xs mt-1 opacity-75">This item could not be located</span>
172
+ </div>
173
+
85
174
  <!-- Spinner while loading -->
86
175
  <div
87
- v-if="!imageLoaded && !imageError"
176
+ v-else-if="(mediaType === 'image' && !imageLoaded && !imageError) || (mediaType === 'video' && !videoLoaded && !videoError)"
88
177
  class="absolute inset-0 flex items-center justify-center bg-slate-100"
89
178
  >
90
179
  <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
@@ -92,24 +181,37 @@ watch(
92
181
 
93
182
  <!-- Error state -->
94
183
  <div
95
- v-if="imageError"
184
+ v-else-if="(mediaType === 'image' && imageError) || (mediaType === 'video' && videoError)"
96
185
  class="absolute inset-0 flex flex-col items-center justify-center bg-slate-50 text-slate-400 text-sm p-4 text-center"
97
186
  >
98
- <i class="fas fa-image text-2xl mb-2 opacity-50"></i>
99
- <span>Failed to load image</span>
187
+ <i :class="mediaType === 'video' ? 'fas fa-video text-2xl mb-2 opacity-50' : 'fas fa-image text-2xl mb-2 opacity-50'"></i>
188
+ <span>Failed to load {{ mediaType }}</span>
100
189
  </div>
101
190
 
102
191
  <!-- Image (only shown when loaded) -->
103
192
  <img
104
- v-if="imageLoaded && imageSrc"
193
+ v-if="mediaType === 'image' && imageLoaded && imageSrc && !showNotFound"
105
194
  :src="imageSrc"
106
195
  class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
107
196
  loading="lazy"
108
197
  decoding="async"
109
198
  />
110
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
+
111
213
  <!-- 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>
214
+ <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
215
 
114
216
  <!-- Remove button -->
115
217
  <button
@@ -122,7 +224,7 @@ watch(
122
224
  </button>
123
225
 
124
226
  <!-- 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">
227
+ <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
228
  <p class="text-white text-xs font-medium truncate drop-shadow-md">Item #{{ String(item.id).split('-')[0] }}</p>
127
229
  </div>
128
230
  </div>
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')