@wyxos/vibe 1.6.3 → 1.6.5
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 +188 -139
- package/lib/demo.webp +0 -0
- package/lib/index.js +518 -423
- package/lib/vibe.css +1 -1
- package/package.json +1 -1
- package/src/App.vue +124 -23
- package/src/Masonry.vue +17 -7
- package/src/components/MasonryItem.vue +132 -0
- package/src/style.css +30 -0
package/lib/vibe.css
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
.masonry-container[data-v-
|
|
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}}
|
package/package.json
CHANGED
package/src/App.vue
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import Masonry from "./Masonry.vue";
|
|
3
|
-
import { ref } from "vue";
|
|
3
|
+
import { ref, reactive, computed } from "vue";
|
|
4
4
|
import fixture from "./pages.json";
|
|
5
5
|
import type { MasonryItem, GetPageResult } from "./types";
|
|
6
6
|
|
|
@@ -8,7 +8,26 @@ const items = ref<MasonryItem[]>([]);
|
|
|
8
8
|
|
|
9
9
|
const masonry = ref<InstanceType<typeof Masonry> | null>(null);
|
|
10
10
|
|
|
11
|
-
const
|
|
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);
|
|
12
31
|
|
|
13
32
|
const getPage = async (page: number): Promise<GetPageResult> => {
|
|
14
33
|
return new Promise((resolve) => {
|
|
@@ -36,28 +55,110 @@ const getPage = async (page: number): Promise<GetPageResult> => {
|
|
|
36
55
|
};
|
|
37
56
|
</script>
|
|
38
57
|
<template>
|
|
39
|
-
<main class="flex flex-col
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
<p
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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>
|
|
69
|
+
</div>
|
|
70
|
+
</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>
|
|
52
95
|
</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>
|
|
53
157
|
</header>
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
</button>
|
|
60
|
-
</template>
|
|
61
|
-
</masonry>
|
|
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>
|
|
62
163
|
</main>
|
|
63
164
|
</template>
|
package/src/Masonry.vue
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { computed, nextTick, onMounted, onUnmounted, ref } from "vue";
|
|
2
|
+
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
|
|
3
3
|
import calculateLayout from "./calculateLayout";
|
|
4
4
|
import { debounce } from 'lodash-es'
|
|
5
5
|
import {
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
} from './masonryUtils'
|
|
11
11
|
import { useMasonryTransitions } from './useMasonryTransitions'
|
|
12
12
|
import { useMasonryScroll } from './useMasonryScroll'
|
|
13
|
+
import MasonryItem from './components/MasonryItem.vue'
|
|
13
14
|
|
|
14
15
|
const props = defineProps({
|
|
15
16
|
getNextPage: {
|
|
@@ -697,6 +698,18 @@ function init(items: any[], page: any, next: any) {
|
|
|
697
698
|
updateScrollProgress()
|
|
698
699
|
}
|
|
699
700
|
|
|
701
|
+
// Watch for layout changes and update columns + refresh layout dynamically
|
|
702
|
+
watch(
|
|
703
|
+
layout,
|
|
704
|
+
() => {
|
|
705
|
+
if (container.value) {
|
|
706
|
+
columns.value = getColumnCount(layout.value as any)
|
|
707
|
+
refreshLayout(masonry.value as any)
|
|
708
|
+
}
|
|
709
|
+
},
|
|
710
|
+
{ deep: true }
|
|
711
|
+
)
|
|
712
|
+
|
|
700
713
|
onMounted(async () => {
|
|
701
714
|
try {
|
|
702
715
|
columns.value = getColumnCount(layout.value as any)
|
|
@@ -738,13 +751,10 @@ onUnmounted(() => {
|
|
|
738
751
|
@before-leave="beforeLeave">
|
|
739
752
|
<div v-for="(item, i) in visibleMasonry" :key="`${item.page}-${item.id}`"
|
|
740
753
|
class="absolute masonry-item"
|
|
741
|
-
v-bind="getItemAttributes(item, i)"
|
|
754
|
+
v-bind="getItemAttributes(item, i)"
|
|
755
|
+
:style="{ paddingTop: `${layout.header}px`, paddingBottom: `${layout.footer}px` }">
|
|
742
756
|
<slot name="item" v-bind="{item, remove}">
|
|
743
|
-
<
|
|
744
|
-
<button class="absolute bottom-0 right-0 bg-red-500 text-white p-2 rounded cursor-pointer"
|
|
745
|
-
@click="remove(item)">
|
|
746
|
-
<i class="fas fa-trash"></i>
|
|
747
|
-
</button>
|
|
757
|
+
<MasonryItem :item="item" :remove="remove" />
|
|
748
758
|
</slot>
|
|
749
759
|
</div>
|
|
750
760
|
</transition-group>
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, onMounted, watch } from 'vue';
|
|
3
|
+
|
|
4
|
+
const props = defineProps<{
|
|
5
|
+
item: any;
|
|
6
|
+
remove?: (item: any) => void;
|
|
7
|
+
}>();
|
|
8
|
+
|
|
9
|
+
const imageLoaded = ref(false);
|
|
10
|
+
const imageError = ref(false);
|
|
11
|
+
const imageSrc = ref<string | null>(null);
|
|
12
|
+
|
|
13
|
+
function preloadImage(src: string): Promise<void> {
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
if (!src) {
|
|
16
|
+
reject(new Error('No image source provided'));
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const img = new Image();
|
|
21
|
+
const startTime = Date.now();
|
|
22
|
+
const minLoadTime = 300; // Minimum time to show spinner (300ms)
|
|
23
|
+
|
|
24
|
+
img.onload = () => {
|
|
25
|
+
const elapsed = Date.now() - startTime;
|
|
26
|
+
const remaining = Math.max(0, minLoadTime - elapsed);
|
|
27
|
+
|
|
28
|
+
// Ensure spinner shows for at least minLoadTime
|
|
29
|
+
setTimeout(() => {
|
|
30
|
+
imageLoaded.value = true;
|
|
31
|
+
imageError.value = false;
|
|
32
|
+
resolve();
|
|
33
|
+
}, remaining);
|
|
34
|
+
};
|
|
35
|
+
img.onerror = () => {
|
|
36
|
+
imageError.value = true;
|
|
37
|
+
imageLoaded.value = false;
|
|
38
|
+
reject(new Error('Failed to load image'));
|
|
39
|
+
};
|
|
40
|
+
img.src = src;
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
onMounted(async () => {
|
|
45
|
+
// Debug: verify component is mounting
|
|
46
|
+
console.log('[MasonryItem] Component mounted', props.item?.id);
|
|
47
|
+
|
|
48
|
+
const src = props.item?.src;
|
|
49
|
+
if (src) {
|
|
50
|
+
imageSrc.value = src;
|
|
51
|
+
// Reset state to ensure spinner shows
|
|
52
|
+
imageLoaded.value = false;
|
|
53
|
+
imageError.value = false;
|
|
54
|
+
try {
|
|
55
|
+
await preloadImage(src);
|
|
56
|
+
} catch {
|
|
57
|
+
// Error handled by imageError state
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
watch(
|
|
63
|
+
() => props.item?.src,
|
|
64
|
+
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
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
);
|
|
77
|
+
</script>
|
|
78
|
+
|
|
79
|
+
<template>
|
|
80
|
+
<div class="relative w-full h-full group">
|
|
81
|
+
<!-- Custom slot content (replaces default if provided) -->
|
|
82
|
+
<slot :item="item" :remove="remove" :imageLoaded="imageLoaded" :imageError="imageError">
|
|
83
|
+
<!-- Default content when no slot is provided -->
|
|
84
|
+
<div class="w-full h-full rounded-xl overflow-hidden shadow-sm hover:shadow-md transition-all duration-300 bg-white relative">
|
|
85
|
+
<!-- Spinner while loading -->
|
|
86
|
+
<div
|
|
87
|
+
v-if="!imageLoaded && !imageError"
|
|
88
|
+
class="absolute inset-0 flex items-center justify-center bg-slate-100"
|
|
89
|
+
>
|
|
90
|
+
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
<!-- Error state -->
|
|
94
|
+
<div
|
|
95
|
+
v-if="imageError"
|
|
96
|
+
class="absolute inset-0 flex flex-col items-center justify-center bg-slate-50 text-slate-400 text-sm p-4 text-center"
|
|
97
|
+
>
|
|
98
|
+
<i class="fas fa-image text-2xl mb-2 opacity-50"></i>
|
|
99
|
+
<span>Failed to load image</span>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
<!-- Image (only shown when loaded) -->
|
|
103
|
+
<img
|
|
104
|
+
v-if="imageLoaded && imageSrc"
|
|
105
|
+
:src="imageSrc"
|
|
106
|
+
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
|
107
|
+
loading="lazy"
|
|
108
|
+
decoding="async"
|
|
109
|
+
/>
|
|
110
|
+
|
|
111
|
+
<!-- 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>
|
|
113
|
+
|
|
114
|
+
<!-- Remove button -->
|
|
115
|
+
<button
|
|
116
|
+
v-if="remove"
|
|
117
|
+
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"
|
|
118
|
+
@click.stop="remove(item)"
|
|
119
|
+
aria-label="Remove item"
|
|
120
|
+
>
|
|
121
|
+
<i class="fas fa-times text-sm"></i>
|
|
122
|
+
</button>
|
|
123
|
+
|
|
124
|
+
<!-- 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">
|
|
126
|
+
<p class="text-white text-xs font-medium truncate drop-shadow-md">Item #{{ String(item.id).split('-')[0] }}</p>
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
</slot>
|
|
130
|
+
</div>
|
|
131
|
+
</template>
|
|
132
|
+
|
package/src/style.css
CHANGED
|
@@ -1,2 +1,32 @@
|
|
|
1
1
|
@import "tailwindcss";
|
|
2
2
|
|
|
3
|
+
|
|
4
|
+
:root {
|
|
5
|
+
font-family: 'Inter', sans-serif;
|
|
6
|
+
-webkit-font-smoothing: antialiased;
|
|
7
|
+
-moz-osx-font-smoothing: grayscale;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
body {
|
|
11
|
+
background-color: #f8fafc;
|
|
12
|
+
color: #0f172a;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/* Custom Scrollbar */
|
|
16
|
+
::-webkit-scrollbar {
|
|
17
|
+
width: 8px;
|
|
18
|
+
height: 8px;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
::-webkit-scrollbar-track {
|
|
22
|
+
background: transparent;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
::-webkit-scrollbar-thumb {
|
|
26
|
+
background: #93c5fd;
|
|
27
|
+
border-radius: 4px;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
::-webkit-scrollbar-thumb:hover {
|
|
31
|
+
background: #60a5fa;
|
|
32
|
+
}
|