mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-07 14:39:30 -07:00
Merge pull request #105 from Utkarsh-Singhal-26/feat/index-page
FEAT: OCTOBER UPDATE
This commit is contained in:
10
src/App.vue
10
src/App.vue
@@ -1,19 +1,23 @@
|
||||
<template>
|
||||
<div>
|
||||
<DisplayHeader v-if="!isCategoryPage" :activeItem="activeItem" />
|
||||
<DisplayHeader v-if="!isSidebarPage" :activeItem="activeItem" />
|
||||
|
||||
<router-view />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import DisplayHeader from '@/components/landing/DisplayHeader/DisplayHeader.vue';
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import DisplayHeader from '@/components/landing/DisplayHeader/DisplayHeader.vue';
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const isCategoryPage = computed(() => /^\/[^/]+\/[^/]+$/.test(route.path));
|
||||
const sidebarPages = ['/favorites'];
|
||||
const isSidebarPage = computed(() => {
|
||||
const path = route.path;
|
||||
return sidebarPages.some(sidebarPath => path.includes(sidebarPath)) || /^\/[^/]+\/[^/]+$/.test(path);
|
||||
});
|
||||
|
||||
const activeItem = computed(() => {
|
||||
if (route.path === '/') return 'home';
|
||||
|
||||
539
src/components/common/ComponentList.vue
Normal file
539
src/components/common/ComponentList.vue
Normal file
@@ -0,0 +1,539 @@
|
||||
<script setup lang="ts">
|
||||
import { componentMetadata, type ComponentMetadata } from '@/constants/Information';
|
||||
import { getSavedComponents, isComponentSaved, removeSavedComponent, toggleSavedComponent } from '@/utils/favorites';
|
||||
import { fuzzyMatch } from '@/utils/fuzzy';
|
||||
import { useVirtualList } from '@vueuse/core';
|
||||
import gsap from 'gsap';
|
||||
import { Trash, X } from 'lucide-vue-next';
|
||||
import Select from 'primevue/select';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import LazyCardMedia from './LazyCardMedia.vue';
|
||||
|
||||
const GAP_PX = 16;
|
||||
const CARD_RADIUS = 30;
|
||||
const CARD_PADDING = 6;
|
||||
const CARD_HEIGHT = 284;
|
||||
const INNER_RADIUS = `${CARD_RADIUS - CARD_PADDING}px`;
|
||||
|
||||
const slug = (str: string) => (str || '').replace(/\s+/g, '-').toLowerCase();
|
||||
const fromPascal = (str: string) =>
|
||||
(str || '')
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.replace(/_/g, ' ')
|
||||
.trim();
|
||||
|
||||
const supportsType = (type: string) => {
|
||||
try {
|
||||
const v = document.createElement('video');
|
||||
if (!('canPlayType' in v)) return false;
|
||||
const res = v.canPlayType(type);
|
||||
return res === 'probably' || res === 'maybe';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const prefersWebM = () => supportsType('video/webm; codecs="vp9,vorbis"') || supportsType('video/webm');
|
||||
|
||||
const pickBestSource = (url: string) => {
|
||||
if (!url) return '';
|
||||
if (url.endsWith('.webm')) {
|
||||
if (prefersWebM()) return url;
|
||||
const mp4 = url.replace(/\.webm$/, '.mp4');
|
||||
return mp4;
|
||||
}
|
||||
if (url.endsWith('.mp4')) {
|
||||
return url;
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
interface NavigatorWithConnection extends Navigator {
|
||||
connection?: {
|
||||
saveData?: boolean;
|
||||
effectiveType?: string;
|
||||
};
|
||||
mozConnection?: {
|
||||
saveData?: boolean;
|
||||
effectiveType?: string;
|
||||
};
|
||||
webkitConnection?: {
|
||||
saveData?: boolean;
|
||||
effectiveType?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const shouldPreload = () => {
|
||||
try {
|
||||
const nav = navigator as NavigatorWithConnection;
|
||||
const c = nav.connection || nav.mozConnection || nav.webkitConnection;
|
||||
if (c?.saveData) return false;
|
||||
const slowTypes = new Set(['slow-2g', '2g']);
|
||||
if (c?.effectiveType && slowTypes.has(c.effectiveType)) return false;
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
type ComponentInfoProps = {
|
||||
list: ComponentMetadata;
|
||||
hasDeleteButton?: boolean;
|
||||
hasFavoriteButton?: boolean;
|
||||
sorting?: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<ComponentInfoProps>(), {
|
||||
hasDeleteButton: false,
|
||||
hasFavoriteButton: false,
|
||||
sorting: 'none'
|
||||
});
|
||||
|
||||
const toast = useToast();
|
||||
const scrollRef = ref<HTMLElement | null>(null);
|
||||
const preloadedSrcsRef = ref(new Set());
|
||||
const hoveredKey = ref<string | null>(null);
|
||||
const clearSlotRef = ref(null);
|
||||
const clearBtnRef = ref(null);
|
||||
const CLEAR_APPEAR_DEBOUNCE_MS = 300;
|
||||
|
||||
const setHoverToItemAtPoint = (x: number, y: number): void => {
|
||||
try {
|
||||
const el = document.elementFromPoint(x, y) as HTMLElement | null;
|
||||
let node: HTMLElement | null = el;
|
||||
while (node && node !== document.body) {
|
||||
if (node.dataset && node.dataset.itemKey) {
|
||||
hoveredKey.value = node.dataset.itemKey;
|
||||
return;
|
||||
}
|
||||
node = node.parentElement as HTMLElement | null;
|
||||
}
|
||||
hoveredKey.value = null;
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
};
|
||||
|
||||
const items = computed(() => {
|
||||
if (!props.list) return [];
|
||||
const entries = Array.isArray(props.list)
|
||||
? props.list
|
||||
: Object.entries(props.list).map(([key, meta]) => ({ key, ...meta }));
|
||||
|
||||
const mapToItem = (entry: ComponentMetadata[keyof ComponentMetadata] | string | { key: string }) => {
|
||||
const key = typeof entry === 'object' && 'key' in entry ? entry.key : typeof entry === 'string' ? entry : null;
|
||||
const meta =
|
||||
typeof entry === 'object' && 'key' in entry
|
||||
? entry
|
||||
: typeof entry === 'string' && componentMetadata?.[entry]
|
||||
? componentMetadata[entry]
|
||||
: {};
|
||||
const fullKey = key || (typeof entry === 'string' ? entry : '');
|
||||
const [cat, comp] = (fullKey || '').split('/');
|
||||
return {
|
||||
key: fullKey,
|
||||
categoryKey: cat,
|
||||
componentKey: comp,
|
||||
categoryLabel: fromPascal(
|
||||
(typeof meta === 'object' && meta && 'category' in meta ? (meta.category as string) : undefined) ?? cat
|
||||
),
|
||||
title: fromPascal(
|
||||
(typeof meta === 'object' && meta && 'name' in meta ? (meta.name as string) : undefined) ?? comp
|
||||
),
|
||||
description:
|
||||
(typeof meta === 'object' && meta && 'description' in meta ? (meta.description as string) : undefined) ?? '',
|
||||
videoUrl: (typeof meta === 'object' && meta && 'videoUrl' in meta ? (meta.videoUrl as string) : undefined) ?? '',
|
||||
tags: typeof meta === 'object' && meta && 'tags' in meta && Array.isArray(meta.tags) ? meta.tags : [],
|
||||
docsUrl: typeof meta === 'object' && meta && 'docsUrl' in meta ? (meta.docsUrl as string | undefined) : undefined
|
||||
};
|
||||
};
|
||||
|
||||
let arr = entries
|
||||
.filter(e => {
|
||||
const key = (e.key ?? e)?.toString?.() ?? '';
|
||||
return key.includes('/') && (e.key ? true : !!componentMetadata[key]);
|
||||
})
|
||||
.map(mapToItem);
|
||||
|
||||
if (props.sorting === 'alphabetical') {
|
||||
arr = arr.sort((a, b) => a.title.localeCompare(b.title));
|
||||
}
|
||||
return arr;
|
||||
});
|
||||
|
||||
const categories = computed(() => {
|
||||
const set = new Set<string>();
|
||||
items.value.forEach(i => i.categoryLabel && set.add(i.categoryLabel));
|
||||
return ['All Components', ...Array.from(set).sort((a, b) => a.localeCompare(b))];
|
||||
});
|
||||
|
||||
const selectedCategory = ref(categories.value[0]);
|
||||
const search = ref('');
|
||||
const savedSet = ref(new Set(getSavedComponents()));
|
||||
|
||||
const update = () => (savedSet.value = new Set(getSavedComponents()));
|
||||
const onStorage = (e?: StorageEvent | null) => {
|
||||
if (!e || e.key === 'savedComponents') update();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('favorites:updated', update);
|
||||
window.addEventListener('storage', onStorage);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('favorites:updated', update);
|
||||
window.removeEventListener('storage', onStorage);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => categories.value,
|
||||
newCategories => {
|
||||
if (!newCategories.includes(selectedCategory.value)) {
|
||||
selectedCategory.value = newCategories[0];
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const filtered = computed(() => {
|
||||
const term = search.value.trim();
|
||||
const all = selectedCategory.value === 'All Components';
|
||||
return items.value.filter(({ title, categoryLabel }) => {
|
||||
const categoryOk = all || categoryLabel === selectedCategory.value;
|
||||
if (!term) return categoryOk;
|
||||
return categoryOk && fuzzyMatch(title, term);
|
||||
});
|
||||
});
|
||||
|
||||
const controlsDisabled = computed(() => items.value.length === 0);
|
||||
const hasCategoryFilter = computed(() => selectedCategory.value !== categories.value[0]);
|
||||
|
||||
const debounceSearch = ref(search.value);
|
||||
|
||||
watch(search, () => {
|
||||
const timeout = setTimeout(() => {
|
||||
debounceSearch.value = search.value;
|
||||
}, CLEAR_APPEAR_DEBOUNCE_MS);
|
||||
return () => clearTimeout(timeout);
|
||||
});
|
||||
|
||||
const showClear = computed(
|
||||
() => !controlsDisabled.value && (hasCategoryFilter.value || (debounceSearch.value?.trim()?.length ?? 0) > 0)
|
||||
);
|
||||
|
||||
watch(showClear, newVal => {
|
||||
const slot = clearSlotRef.value;
|
||||
const btn = clearBtnRef.value;
|
||||
if (!slot || !btn) return;
|
||||
gsap.killTweensOf([slot, btn]);
|
||||
|
||||
if (newVal) {
|
||||
const tl = gsap.timeline();
|
||||
tl.to(slot, { width: 40, duration: 0.3, ease: 'power2.out' }).fromTo(
|
||||
btn,
|
||||
{ scale: 0.6, opacity: 0 },
|
||||
{ scale: 1, opacity: 1, duration: 0.25, ease: 'power2.out', force3D: true },
|
||||
'<0.05'
|
||||
);
|
||||
} else {
|
||||
const tl = gsap.timeline();
|
||||
tl.to(btn, { scale: 0, opacity: 0, duration: 0.15, ease: 'power2.in' }).to(
|
||||
slot,
|
||||
{ width: 0, duration: 0.25, ease: 'power2.inOut' },
|
||||
'+=0'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const getColumnsForWidth = (w: number) => (w >= 1024 ? 3 : w >= 768 ? 2 : 1);
|
||||
|
||||
const preloadRange = (startIdx: number, endIdx: number) => {
|
||||
if (!shouldPreload()) return;
|
||||
const urls = [];
|
||||
for (let i = startIdx; i <= Math.min(endIdx, filtered.value.length - 1); i++) {
|
||||
const url = filtered.value[i]?.videoUrl;
|
||||
if (!url) continue;
|
||||
const chosen = pickBestSource(url);
|
||||
if (chosen && !preloadedSrcsRef.value.has(chosen)) {
|
||||
urls.push(chosen);
|
||||
}
|
||||
}
|
||||
if (urls.length === 0) return;
|
||||
urls.forEach(src => {
|
||||
try {
|
||||
const v = document.createElement('video');
|
||||
v.preload = 'metadata';
|
||||
v.src = src;
|
||||
const mark = () => {
|
||||
preloadedSrcsRef.value.add(src);
|
||||
};
|
||||
v.addEventListener('loadedmetadata', mark, { once: true });
|
||||
v.addEventListener('loadeddata', mark, { once: true });
|
||||
v.addEventListener('canplaythrough', mark, { once: true });
|
||||
v.load();
|
||||
setTimeout(() => {
|
||||
v.src = '';
|
||||
}, 8000);
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
selectedCategory.value = categories.value[0];
|
||||
search.value = '';
|
||||
};
|
||||
|
||||
const handleDeletion = (e: MouseEvent, item: { key: string }): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const { clientX, clientY } = e as MouseEvent & { clientX: number; clientY: number };
|
||||
const next = removeSavedComponent(item.key);
|
||||
savedSet.value = new Set(next);
|
||||
setHoverToItemAtPoint(clientX, clientY);
|
||||
|
||||
const target = e.currentTarget as HTMLElement | null;
|
||||
if (target && typeof target.blur === 'function') {
|
||||
target.blur();
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
const schedule: (cb: (ts?: number) => void) => number = window.requestAnimationFrame
|
||||
? (cb: (ts?: number) => void) => window.requestAnimationFrame(cb as FrameRequestCallback)
|
||||
: (cb: (ts?: number) => void) => window.setTimeout(() => cb(Date.now()), 0);
|
||||
schedule(() => setHoverToItemAtPoint(clientX, clientY));
|
||||
}
|
||||
};
|
||||
|
||||
const isSaved = (key: string): boolean => savedSet.value.has(key) || isComponentSaved(key);
|
||||
|
||||
const handleFavoriteToggle = (e: MouseEvent, item: { key: string; componentKey: string }): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const { saved, list: next } = toggleSavedComponent(item.key);
|
||||
savedSet.value = new Set(next);
|
||||
|
||||
toast.add({
|
||||
severity: saved ? 'success' : 'error',
|
||||
summary: saved ? `Added <${item.componentKey} /> to Favorites` : `Removed <${item.componentKey} /> from Favorites`,
|
||||
life: 3000
|
||||
});
|
||||
|
||||
const target = e.currentTarget as HTMLElement | null;
|
||||
if (target && typeof target.blur === 'function') {
|
||||
target.blur();
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- VIRTUALIZATION ----------
|
||||
const containerWidth = ref(window.innerWidth);
|
||||
|
||||
const updateContainerWidth = () => {
|
||||
if (scrollRef.value) {
|
||||
containerWidth.value = scrollRef.value.offsetWidth;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
updateContainerWidth();
|
||||
window.addEventListener('resize', updateContainerWidth);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', updateContainerWidth);
|
||||
});
|
||||
|
||||
const columnCount = computed(() => getColumnsForWidth(containerWidth.value));
|
||||
const rowHeight = computed(() => CARD_HEIGHT + GAP_PX);
|
||||
|
||||
const gridRows = computed(() => {
|
||||
const rows = [];
|
||||
const cols = columnCount.value;
|
||||
for (let i = 0; i < filtered.value.length; i += cols) {
|
||||
rows.push(filtered.value.slice(i, i + cols));
|
||||
}
|
||||
return rows;
|
||||
});
|
||||
|
||||
const {
|
||||
list: virtualedRows,
|
||||
containerProps,
|
||||
wrapperProps
|
||||
} = useVirtualList(gridRows, {
|
||||
itemHeight: rowHeight.value
|
||||
});
|
||||
|
||||
watch(virtualedRows, newRows => {
|
||||
if (newRows.length > 0) {
|
||||
const lastRow = newRows[newRows.length - 1];
|
||||
const lastVisibleIndex = (lastRow.index + 1) * columnCount.value - 1;
|
||||
preloadRange(lastVisibleIndex + 1, lastVisibleIndex + 3);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="scrollRef">
|
||||
<div class="flex md:flex-row flex-col justify-start md:justify-between items-start md:items-center gap-4 mb-3">
|
||||
<h2 v-if="title" class="sub-category" style="margin: 0">
|
||||
{{ title }}
|
||||
</h2>
|
||||
|
||||
<div
|
||||
class="flex md:flex-row flex-col items-center gap-2 w-full md:w-auto"
|
||||
:style="{ opacity: controlsDisabled ? 0.6 : 1 }"
|
||||
>
|
||||
<div
|
||||
class="relative flex items-center gap-2 bg-[#0b0b0b] px-4 pr-8 border border-[#333] rounded-full w-full md:w-[180px] h-10 font-semibold text-[12px] cursor-text select-none"
|
||||
:class="{ 'opacity-60 pointer-events-none': controlsDisabled }"
|
||||
>
|
||||
<i class="pi pi-search search-icon"></i>
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
class="flex-1 bg-transparent border-none outline-none placeholder:font-medium text-[#a6a6a6] placeholder-[#a6a6a6]"
|
||||
:disabled="controlsDisabled"
|
||||
:tabindex="controlsDisabled ? -1 : 0"
|
||||
@focus="
|
||||
(e: FocusEvent) => {
|
||||
if (controlsDisabled) (e.target as HTMLInputElement).blur();
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
v-model="selectedCategory"
|
||||
:options="categories"
|
||||
class="w-full md:w-auto"
|
||||
:disabled="controlsDisabled"
|
||||
/>
|
||||
|
||||
<div ref="clearSlotRef" class="hidden md:flex justify-center items-center w-0 overflow-hidden">
|
||||
<button
|
||||
ref="clearBtnRef"
|
||||
aria-label="Clear filters"
|
||||
@click="clearFilters"
|
||||
class="flex justify-center items-center bg-transparent hover:bg-[#0d2717] opacity-0 focus-visible:shadow-none focus:shadow-none border border-[#333] focus-visible:border-[#333] rounded-full focus-visible:outline-none focus:outline-none focus:ring-0 w-10 h-10 text-[#a6a6a6] origin-[50%_50%] transition-all duration-300"
|
||||
:class="{
|
||||
'pointer-events-none': !showClear,
|
||||
'pointer-events-auto': showClear
|
||||
}"
|
||||
:tabindex="showClear ? 0 : -1"
|
||||
>
|
||||
<X class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<div v-if="filtered.length === 0" class="relative mt-[6em] p-6 text-center" role="status">
|
||||
<div class="relative">
|
||||
<h3 class="mb-1 font-medium text-white text-2xl">
|
||||
{{ items.length > 0 ? 'No results...' : 'Nothing here yet...' }}
|
||||
</h3>
|
||||
<h4 class="mb-8 text-[#a6a6a6] text-base">
|
||||
{{ items.length > 0 ? 'Try adjusting your filters' : 'Tap the heart on any component to save it' }}
|
||||
</h4>
|
||||
|
||||
<div class="flex flex-wrap justify-center gap-2">
|
||||
<button
|
||||
v-if="items.length > 0"
|
||||
class="bg-transparent hover:bg-[#0d2717] px-4 border border-[#333] rounded-full h-10 font-medium text-white transition-colors duration-300 cursor-pointer"
|
||||
>
|
||||
Clear Filters
|
||||
</button>
|
||||
<component
|
||||
:is="'router-link'"
|
||||
to="/get-started/index"
|
||||
v-else
|
||||
class="flex justify-center items-center bg-transparent hover:bg-[#0d2717] px-4 border border-[#333] rounded-full h-10 font-medium text-white transition-colors duration-300 cursor-pointer"
|
||||
>
|
||||
Browse Components
|
||||
</component>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else v-bind="containerProps">
|
||||
<div v-bind="wrapperProps">
|
||||
<div
|
||||
v-for="row in virtualedRows"
|
||||
:key="row.index"
|
||||
:style="{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${columnCount}, 1fr)`,
|
||||
gap: `${GAP_PX}px`,
|
||||
marginBottom: `${GAP_PX}px`
|
||||
}"
|
||||
>
|
||||
<component
|
||||
v-for="item in row.data"
|
||||
:key="item.key"
|
||||
:is="'router-link'"
|
||||
:to="`/${slug(fromPascal(item.categoryKey))}/${slug(fromPascal(item.componentKey))}` || '#'"
|
||||
:data-item-key="item.key"
|
||||
:style="{
|
||||
backgroundColor: '#222',
|
||||
borderRadius: `${CARD_RADIUS}px`,
|
||||
padding: `${CARD_PADDING}px`
|
||||
}"
|
||||
@mouseenter="hoveredKey = item.key"
|
||||
@mouseleave="hoveredKey = null"
|
||||
>
|
||||
<div class="relative px-4 py-3">
|
||||
<h3 class="font-medium text-white text-base leading-[1.4]">{{ item.title }}</h3>
|
||||
<p class="font-normal text-[#27ff64] text-xs">{{ item.categoryLabel }}</p>
|
||||
|
||||
<button
|
||||
aria-label="Remove from favorites"
|
||||
v-if="hasDeleteButton"
|
||||
class="top-2 right-2 absolute flex justify-center items-center bg-transparent hover:bg-[#0d2717] focus:opacity-100 border border-[#333] rounded-full w-10 h-10 text-[#a6a6a6] transition-opacity duration-150 ease-in-out cursor-pointer focus:pointer-events-auto"
|
||||
:class="{
|
||||
'opacity-0 pointer-events-none': hoveredKey !== item.key,
|
||||
'opacity-100 pointer-events-auto': hoveredKey === item.key
|
||||
}"
|
||||
@click="handleDeletion($event, item)"
|
||||
>
|
||||
<Trash class="size-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
:aria-label="isSaved(item.key) ? 'Remove from favorites' : 'Add to favorites'"
|
||||
v-if="!hasDeleteButton && hasFavoriteButton"
|
||||
class="top-2 right-2 absolute flex justify-center items-center bg-transparent hover:bg-[#0d2717] focus:opacity-100 border border-[#333] rounded-full w-10 h-10 text-[#a6a6a6] transition-opacity duration-150 ease-in-out cursor-pointer focus:pointer-events-auto"
|
||||
:class="{
|
||||
'opacity-0 pointer-events-none': hoveredKey !== item.key,
|
||||
'opacity-100 pointer-events-auto': hoveredKey === item.key
|
||||
}"
|
||||
@click="handleFavoriteToggle($event, item)"
|
||||
>
|
||||
<i :class="isSaved(item.key) ? 'pi pi-heart-fill' : 'pi pi-heart'" :style="{ color: '#ffffff' }"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<LazyCardMedia
|
||||
:key="item.videoUrl || item.key"
|
||||
:video-url="item.videoUrl"
|
||||
:active="true"
|
||||
:inner-radius="INNER_RADIUS"
|
||||
/>
|
||||
</component>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
::-webkit-scrollbar {
|
||||
width: 0;
|
||||
}
|
||||
</style>
|
||||
189
src/components/common/LazyCardMedia.vue
Normal file
189
src/components/common/LazyCardMedia.vue
Normal file
@@ -0,0 +1,189 @@
|
||||
<template>
|
||||
<div ref="containerRef" class="bg-black h-[200px] overflow-hidden" :style="{ borderRadius: innerRadius }">
|
||||
<video
|
||||
v-if="show"
|
||||
ref="videoRef"
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
preload="metadata"
|
||||
:style="{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
display: 'block',
|
||||
pointerEvents: 'none'
|
||||
}"
|
||||
>
|
||||
<!-- Let the browser choose the best supported source -->
|
||||
<source :src="webm" type="video/webm" />
|
||||
<source :src="mp4" type="video/mp4" />
|
||||
</video>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch, nextTick } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
videoUrl: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
active: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
innerRadius: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '0px'
|
||||
}
|
||||
});
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null);
|
||||
const videoRef = ref<HTMLVideoElement | null>(null);
|
||||
const isVisible = ref(false);
|
||||
const show = computed(() => !!props.videoUrl && !!props.active && isVisible.value);
|
||||
|
||||
const base = computed(() => (props.videoUrl ? props.videoUrl.replace(/\.(webm|mp4)$/i, '') : ''));
|
||||
const webm = computed(() => (base.value ? `${base.value}.webm` : ''));
|
||||
const mp4 = computed(() => (base.value ? `${base.value}.mp4` : ''));
|
||||
|
||||
let mounted = true;
|
||||
let cleanup: (() => void) | null = null;
|
||||
|
||||
const tryPlay = () => {
|
||||
const v = videoRef.value;
|
||||
if (!v || !mounted || !show.value) return;
|
||||
|
||||
try {
|
||||
const p = v.play();
|
||||
if (p && typeof p.then === 'function') {
|
||||
p.catch(() => {
|
||||
// Autoplay was prevented, try again after user interaction
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ignore autoplay errors
|
||||
}
|
||||
};
|
||||
|
||||
const setupVideo = async () => {
|
||||
if (cleanup) {
|
||||
cleanup();
|
||||
cleanup = null;
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
|
||||
const v = videoRef.value;
|
||||
if (!v || !show.value) return;
|
||||
|
||||
if (v.readyState >= 3) {
|
||||
tryPlay();
|
||||
return;
|
||||
}
|
||||
|
||||
const onLoadedMeta = () => tryPlay();
|
||||
const onCanPlay = () => tryPlay();
|
||||
const onLoadedData = () => tryPlay();
|
||||
const onCanPlayThrough = () => tryPlay();
|
||||
const onError = () => {
|
||||
console.error('Video failed to load:', props.videoUrl);
|
||||
};
|
||||
|
||||
v.addEventListener('loadedmetadata', onLoadedMeta);
|
||||
v.addEventListener('canplay', onCanPlay);
|
||||
v.addEventListener('loadeddata', onLoadedData);
|
||||
v.addEventListener('canplaythrough', onCanPlayThrough);
|
||||
v.addEventListener('error', onError);
|
||||
|
||||
try {
|
||||
v.load();
|
||||
} catch {
|
||||
// ignore load errors
|
||||
}
|
||||
|
||||
const id = setTimeout(tryPlay, 1200);
|
||||
|
||||
cleanup = () => {
|
||||
clearTimeout(id);
|
||||
v.removeEventListener('loadedmetadata', onLoadedMeta);
|
||||
v.removeEventListener('canplay', onCanPlay);
|
||||
v.removeEventListener('loadeddata', onLoadedData);
|
||||
v.removeEventListener('canplaythrough', onCanPlayThrough);
|
||||
v.removeEventListener('error', onError);
|
||||
};
|
||||
};
|
||||
|
||||
const pauseVideo = () => {
|
||||
const v = videoRef.value;
|
||||
if (!v) return;
|
||||
|
||||
try {
|
||||
v.pause();
|
||||
} catch {
|
||||
// ignore pause errors
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
show,
|
||||
async newVal => {
|
||||
if (newVal) {
|
||||
await setupVideo();
|
||||
} else {
|
||||
pauseVideo();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
entries => {
|
||||
entries.forEach(entry => {
|
||||
isVisible.value = entry.isIntersecting;
|
||||
});
|
||||
},
|
||||
{
|
||||
threshold: 0.1,
|
||||
rootMargin: '50px'
|
||||
}
|
||||
);
|
||||
|
||||
if (containerRef.value) {
|
||||
observer.observe(containerRef.value);
|
||||
}
|
||||
|
||||
const observerCleanup = () => {
|
||||
if (containerRef.value) {
|
||||
observer.unobserve(containerRef.value);
|
||||
}
|
||||
observer.disconnect();
|
||||
};
|
||||
|
||||
const originalCleanup = cleanup;
|
||||
cleanup = () => {
|
||||
if (originalCleanup) {
|
||||
originalCleanup();
|
||||
}
|
||||
observerCleanup();
|
||||
};
|
||||
|
||||
if (show.value) {
|
||||
setupVideo();
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
mounted = false;
|
||||
if (cleanup) {
|
||||
cleanup();
|
||||
cleanup = null;
|
||||
}
|
||||
pauseVideo();
|
||||
});
|
||||
</script>
|
||||
@@ -5,7 +5,6 @@
|
||||
<Tab value="0">
|
||||
<div class="tab-header">
|
||||
<i class="pi pi-eye"></i>
|
||||
|
||||
<span>Preview</span>
|
||||
</div>
|
||||
</Tab>
|
||||
@@ -13,7 +12,6 @@
|
||||
<Tab value="1">
|
||||
<div class="tab-header">
|
||||
<i class="pi pi-code"></i>
|
||||
|
||||
<span>Code</span>
|
||||
</div>
|
||||
</Tab>
|
||||
@@ -21,16 +19,54 @@
|
||||
<Tab value="2">
|
||||
<div class="tab-header">
|
||||
<i class="pi pi-box"></i>
|
||||
|
||||
<span>CLI</span>
|
||||
</div>
|
||||
</Tab>
|
||||
|
||||
<Tab value="3">
|
||||
<div class="tab-header">
|
||||
<i class="pi pi-heart"></i>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<div class="inline-block relative" v-if="favoriteKey && category !== 'get-started'">
|
||||
<button
|
||||
@click.stop="toggleFavorite"
|
||||
@mouseenter="showTooltip"
|
||||
@mouseleave="hideTooltip"
|
||||
:aria-label="isSaved ? 'Remove from Favorites' : 'Add to Favorites'"
|
||||
:aria-pressed="isSaved"
|
||||
class="flex justify-center items-center rounded-lg w-10 h-10 transition-all duration-200 cursor-pointer"
|
||||
:class="
|
||||
isSaved
|
||||
? 'bg-linear-to-br from-[#1ea03f] to-[#182fff99] hover:brightness-90'
|
||||
: 'border border-[#222] hover:bg-[#222]'
|
||||
"
|
||||
>
|
||||
<i :class="isSaved ? 'pi pi-heart-fill' : 'pi pi-heart'" :style="{ color: '#ffffff' }"></i>
|
||||
</button>
|
||||
|
||||
<span>Contribute</span>
|
||||
<Transition
|
||||
enter-active-class="transition-opacity duration-200"
|
||||
leave-active-class="transition-opacity duration-100"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="isTooltipVisible"
|
||||
class="top-1/2 right-full absolute mr-2 -translate-y-1/2 pointer-events-none"
|
||||
>
|
||||
<div
|
||||
class="flex justify-center items-center bg-transparent px-4 border border-[#222] rounded-[15px] h-10 font-medium text-white text-xs whitespace-nowrap"
|
||||
>
|
||||
{{ isSaved ? 'Remove from Favorites' : 'Add to Favorites' }}
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<div class="tab-header">
|
||||
<i class="pi pi-lightbulb"></i>
|
||||
<span>Contribute</span>
|
||||
</div>
|
||||
</div>
|
||||
</Tab>
|
||||
</TabList>
|
||||
@@ -57,12 +93,105 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Tabs from 'primevue/tabs';
|
||||
import TabList from 'primevue/tablist';
|
||||
import { getSavedComponents, isComponentSaved, toggleSavedComponent } from '@/utils/favorites';
|
||||
import Tab from 'primevue/tab';
|
||||
import TabPanels from 'primevue/tabpanels';
|
||||
import TabList from 'primevue/tablist';
|
||||
import TabPanel from 'primevue/tabpanel';
|
||||
import TabPanels from 'primevue/tabpanels';
|
||||
import Tabs from 'primevue/tabs';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import ContributionSection from './ContributionSection.vue';
|
||||
|
||||
const savedSet = ref(new Set(getSavedComponents()));
|
||||
const isSaved = ref<boolean>(false);
|
||||
|
||||
const route = useRoute();
|
||||
const category = computed(() => route.params.category);
|
||||
const subcategory = computed(() => route.params.subcategory);
|
||||
const toast = useToast();
|
||||
|
||||
const isTooltipVisible = ref(false);
|
||||
const showTimeout = ref<number | null>(null);
|
||||
const hideTimeout = ref<number | null>(null);
|
||||
|
||||
const toPascal = (str: string) =>
|
||||
str
|
||||
.split('-')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join('');
|
||||
|
||||
const favoriteKey = computed<string | null>(() => {
|
||||
if (!category.value || !subcategory.value) return null;
|
||||
const categoryName = toPascal(category.value as string);
|
||||
const compName = toPascal(subcategory.value as string);
|
||||
return `${categoryName}/${compName}`;
|
||||
});
|
||||
|
||||
const componentName = computed<string | null>(() => {
|
||||
if (!subcategory.value) return null;
|
||||
return toPascal(subcategory.value as string);
|
||||
});
|
||||
|
||||
const updateSaved = () => (savedSet.value = new Set(getSavedComponents()));
|
||||
const onStorage = (e?: StorageEvent | null) => {
|
||||
if (!e || e.key === 'savedComponents') updateSaved();
|
||||
};
|
||||
const toggleFavorite = () => {
|
||||
if (!favoriteKey.value) return;
|
||||
|
||||
try {
|
||||
const { saved } = toggleSavedComponent(favoriteKey.value);
|
||||
isSaved.value = saved;
|
||||
|
||||
toast.add({
|
||||
severity: saved ? 'success' : 'error',
|
||||
summary: saved
|
||||
? `Added <${componentName.value} /> to Favorites`
|
||||
: `Removed <${componentName.value} /> from Favorites`,
|
||||
life: 3000
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error toggling favorite:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const showTooltip = () => {
|
||||
clearTimeout(hideTimeout.value ?? 0);
|
||||
showTimeout.value = setTimeout(() => {
|
||||
isTooltipVisible.value = true;
|
||||
}, 250);
|
||||
};
|
||||
|
||||
const hideTooltip = () => {
|
||||
clearTimeout(showTimeout.value ?? 0);
|
||||
hideTimeout.value = setTimeout(() => {
|
||||
isTooltipVisible.value = false;
|
||||
}, 100);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('favorites:updated', updateSaved);
|
||||
window.addEventListener('storage', onStorage);
|
||||
updateSaved();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('favorites:updated', updateSaved);
|
||||
window.removeEventListener('storage', onStorage);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => favoriteKey.value,
|
||||
() => {
|
||||
if (!favoriteKey.value) return;
|
||||
isSaved.value = isComponentSaved(favoriteKey.value);
|
||||
},
|
||||
{
|
||||
immediate: true
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -24,6 +24,35 @@
|
||||
</button>
|
||||
</FadeContent>
|
||||
|
||||
<div class="inline-block relative">
|
||||
<router-link
|
||||
to="/favorites"
|
||||
@mouseenter="showTooltip"
|
||||
@mouseleave="hideTooltip"
|
||||
aria-label="Favorites"
|
||||
class="flex justify-center items-center bg-linear-to-br from-[#1ea03f] to-[#182fff99] hover:brightness-110 active:brightness-95 rounded-full w-10 h-10 transition-all duration-200 cursor-pointer"
|
||||
>
|
||||
<i class="pi pi-heart-fill" :style="{ color: '#ffffff' }"></i>
|
||||
</router-link>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition-opacity duration-200"
|
||||
leave-active-class="transition-opacity duration-100"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div v-if="isTooltipVisible" class="top-full left-1/2 absolute mt-2 -translate-x-1/2 pointer-events-none">
|
||||
<div
|
||||
class="flex justify-center items-center bg-[#0b0b0b] px-4 py-2 border border-[#333] rounded-[50px] font-semibold text-white text-xs whitespace-nowrap"
|
||||
>
|
||||
Favorites
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<FadeContent blur>
|
||||
<button class="cta-button-docs" @click="openGitHub">
|
||||
Star On GitHub
|
||||
@@ -69,10 +98,11 @@
|
||||
<p class="section-title">Useful Links</p>
|
||||
|
||||
<router-link to="/text-animations/split-text" @click="closeDrawer" class="drawer-link">Docs</router-link>
|
||||
<router-link to="/favorites" @click="closeDrawer" class="drawer-link">Favorites</router-link>
|
||||
|
||||
<a href="https://github.com/DavidHDev/vue-bits" target="_blank" @click="closeDrawer" class="drawer-link">
|
||||
GitHub
|
||||
<i class="pi pi-arrow-up-right arrow-icon"></i>
|
||||
<i class="pi-arrow-up-right pi arrow-icon"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -83,7 +113,7 @@
|
||||
|
||||
<a href="https://davidhaz.com/" target="_blank" @click="closeDrawer" class="drawer-link">
|
||||
Who made this?
|
||||
<i class="pi pi-arrow-up-right arrow-icon"></i>
|
||||
<i class="pi-arrow-up-right pi arrow-icon"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -110,6 +140,10 @@ const stars = useStars();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const isTooltipVisible = ref(false);
|
||||
const showTimeout = ref<number | null>(null);
|
||||
const hideTimeout = ref<number | null>(null);
|
||||
|
||||
const slug = (str: string) => str.replace(/\s+/g, '-').toLowerCase();
|
||||
|
||||
const toggleDrawer = () => {
|
||||
@@ -129,6 +163,20 @@ const onNavClick = () => {
|
||||
window.scrollTo(0, 0);
|
||||
};
|
||||
|
||||
const showTooltip = () => {
|
||||
clearTimeout(hideTimeout.value ?? 0);
|
||||
showTimeout.value = setTimeout(() => {
|
||||
isTooltipVisible.value = true;
|
||||
}, 250);
|
||||
};
|
||||
|
||||
const hideTooltip = () => {
|
||||
clearTimeout(showTimeout.value ?? 0);
|
||||
hideTimeout.value = setTimeout(() => {
|
||||
isTooltipVisible.value = false;
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const handleMobileTransitionNavigation = async (path: string) => {
|
||||
if (isTransitioning.value || route.path === path) return;
|
||||
|
||||
|
||||
@@ -37,20 +37,17 @@
|
||||
<div class="links-container">
|
||||
<a href="https://github.com/DavidHDev/vue-bits" target="_blank" @click="closeDrawer" class="useful-link">
|
||||
<span>GitHub</span>
|
||||
|
||||
<i class="pi pi-arrow-up-right arrow-icon"></i>
|
||||
<i class="pi-arrow-up-right pi arrow-icon"></i>
|
||||
</a>
|
||||
|
||||
<router-link to="/text-animations/split-text" @click="closeDrawer" class="useful-link">
|
||||
<span>Docs</span>
|
||||
|
||||
<i class="pi pi-arrow-up-right arrow-icon"></i>
|
||||
<i class="pi-arrow-up-right pi arrow-icon"></i>
|
||||
</router-link>
|
||||
|
||||
<a href="https://davidhaz.com/" target="_blank" @click="closeDrawer" class="useful-link">
|
||||
<span>Who made this?</span>
|
||||
|
||||
<i class="pi pi-arrow-up-right arrow-icon"></i>
|
||||
<i class="pi-arrow-up-right pi arrow-icon"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -101,10 +98,22 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, nextTick, watch, defineComponent, h, computed, useTemplateRef } from 'vue';
|
||||
import { getSavedComponents } from '@/utils/favorites';
|
||||
import {
|
||||
computed,
|
||||
defineComponent,
|
||||
h,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
useTemplateRef,
|
||||
watch
|
||||
} from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { CATEGORIES, NEW, UPDATED } from '../../constants/Categories';
|
||||
import Logo from '../../assets/logos/vue-bits-logo.svg';
|
||||
import { CATEGORIES, NEW, UPDATED } from '../../constants/Categories';
|
||||
import '../../css/sidebar.css';
|
||||
|
||||
const HOVER_TIMEOUT_DELAY = 150;
|
||||
@@ -123,12 +132,18 @@ const sidebarContainerRef = useTemplateRef<HTMLDivElement>('sidebarContainerRef'
|
||||
|
||||
let hoverTimeoutRef: number | null = null;
|
||||
let hoverDelayTimeoutRef: number | null = null;
|
||||
const savedSet = ref(new Set(getSavedComponents()));
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const scrollToTop = () => window.scrollTo(0, 0);
|
||||
const slug = (str: string) => str.replace(/\s+/g, '-').toLowerCase();
|
||||
const toPascal = (str: string) =>
|
||||
str
|
||||
.split('-')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join('');
|
||||
|
||||
const findActiveElement = () => {
|
||||
const activePath = pendingActivePath.value || route.path;
|
||||
@@ -240,6 +255,22 @@ const updateActiveLine = async () => {
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const updateSaved = () => (savedSet.value = new Set(getSavedComponents()));
|
||||
const onStorage = (e?: StorageEvent | null) => {
|
||||
if (!e || e.key === 'savedComponents') updateSaved();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('favorites:updated', updateSaved);
|
||||
window.addEventListener('storage', onStorage);
|
||||
updateSaved();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('favorites:updated', updateSaved);
|
||||
window.removeEventListener('storage', onStorage);
|
||||
});
|
||||
|
||||
const Category = defineComponent({
|
||||
name: 'Category',
|
||||
props: {
|
||||
@@ -274,6 +305,10 @@ const Category = defineComponent({
|
||||
isTransitioning: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
savedSet: {
|
||||
type: Object,
|
||||
default: () => savedSet.value
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
@@ -283,18 +318,21 @@ const Category = defineComponent({
|
||||
isActive: boolean;
|
||||
isNew: boolean;
|
||||
isUpdated: boolean;
|
||||
isFavorited: boolean;
|
||||
}
|
||||
|
||||
const items = computed(() =>
|
||||
props.category.subcategories.map((sub: string): ItemType => {
|
||||
const path = `/${slug(props.category.name)}/${slug(sub)}`;
|
||||
const activePath = props.pendingActivePath || props.location.path;
|
||||
const favoriteKey = `${toPascal(slug(props.category.name))}/${toPascal(slug(sub))}`;
|
||||
return {
|
||||
sub,
|
||||
path,
|
||||
isActive: activePath === path,
|
||||
isNew: (NEW as string[]).includes(sub),
|
||||
isUpdated: (UPDATED as string[]).includes(sub)
|
||||
isUpdated: (UPDATED as string[]).includes(sub),
|
||||
isFavorited: savedSet.value?.has?.(favoriteKey)
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -305,7 +343,7 @@ const Category = defineComponent({
|
||||
h(
|
||||
'div',
|
||||
{ class: 'category-items' },
|
||||
items.value.map(({ sub, path, isActive, isNew, isUpdated }: ItemType) => {
|
||||
items.value.map(({ sub, path, isActive, isNew, isUpdated, isFavorited }: ItemType) => {
|
||||
return h(
|
||||
'router-link',
|
||||
{
|
||||
@@ -324,7 +362,15 @@ const Category = defineComponent({
|
||||
[
|
||||
sub,
|
||||
isNew ? h('span', { class: 'new-tag' }, 'New') : null,
|
||||
isUpdated ? h('span', { class: 'updated-tag' }, 'Updated') : null
|
||||
isUpdated ? h('span', { class: 'updated-tag' }, 'Updated') : null,
|
||||
isFavorited
|
||||
? h('i', {
|
||||
class: 'pi pi-heart-fill footer-heart',
|
||||
style: {
|
||||
marginLeft: '6px'
|
||||
}
|
||||
})
|
||||
: null
|
||||
].filter(Boolean)
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,139 +1,141 @@
|
||||
// Highlighted sidebar items
|
||||
export const NEW = ['Color Bends', 'Ghost Cursor', 'Laser Flow', 'Dome Gallery', 'Liquid Ether', 'Staggered Menu', 'Pixel Blast', 'Bubble Menu', 'Pill Nav', 'Card Nav'];
|
||||
export const NEW = ['Color Bends', 'Ghost Cursor', 'Laser Flow', 'Liquid Ether', 'Pixel Blast'];
|
||||
export const UPDATED = [];
|
||||
|
||||
// Used for main sidebar navigation
|
||||
export const CATEGORIES = [
|
||||
{
|
||||
name: 'Get Started',
|
||||
subcategories: ['Index']
|
||||
},
|
||||
{
|
||||
name: 'Text Animations',
|
||||
subcategories: [
|
||||
'Split Text',
|
||||
'Ascii Text',
|
||||
'Blur Text',
|
||||
'Circular Text',
|
||||
'Shiny Text',
|
||||
'Text Pressure',
|
||||
'Curved Loop',
|
||||
'Fuzzy Text',
|
||||
'Gradient Text',
|
||||
'Text Trail',
|
||||
'Falling Text',
|
||||
'Text Cursor',
|
||||
'Decrypted Text',
|
||||
'Ascii Text',
|
||||
'Scramble Text',
|
||||
'True Focus',
|
||||
'Falling Text',
|
||||
'Fuzzy Text',
|
||||
'Glitch Text',
|
||||
'Gradient Text',
|
||||
'Rotating Text',
|
||||
'Scroll Float',
|
||||
'Scroll Reveal',
|
||||
'Rotating Text',
|
||||
'Glitch Text',
|
||||
'Scroll Velocity',
|
||||
'Scramble Text',
|
||||
'Shiny Text',
|
||||
'Split Text',
|
||||
'Text Cursor',
|
||||
'Text Pressure',
|
||||
'Text Trail',
|
||||
'Text Type',
|
||||
'True Focus',
|
||||
'Variable Proximity',
|
||||
]
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Animations',
|
||||
subcategories: [
|
||||
'Animated Content',
|
||||
'Blob Cursor',
|
||||
'Click Spark',
|
||||
'Count Up',
|
||||
'Crosshair',
|
||||
'Cubes',
|
||||
'Electric Border',
|
||||
'Fade Content',
|
||||
'Ghost Cursor',
|
||||
'Glare Hover',
|
||||
'Gradual Blur',
|
||||
'Laser Flow',
|
||||
'Noise',
|
||||
'Splash Cursor',
|
||||
'Logo Loop',
|
||||
'Pixel Transition',
|
||||
'Target Cursor',
|
||||
'Ghost Cursor',
|
||||
'Electric Border',
|
||||
'Sticker Peel',
|
||||
'Ribbons',
|
||||
'Glare Hover',
|
||||
'Magnet Lines',
|
||||
'Count Up',
|
||||
'Metallic Paint',
|
||||
'Click Spark',
|
||||
'Magnet',
|
||||
'Cubes',
|
||||
'Blob Cursor',
|
||||
'Meta Balls',
|
||||
'Image Trail',
|
||||
'Magnet Lines',
|
||||
'Metallic Paint',
|
||||
'Noise',
|
||||
'Pixel Transition',
|
||||
'Ribbons',
|
||||
'Shape Blur',
|
||||
'Crosshair',
|
||||
'Splash Cursor',
|
||||
'Star Border',
|
||||
]
|
||||
'Sticker Peel',
|
||||
'Target Cursor',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Components',
|
||||
subcategories: [
|
||||
'Animated List',
|
||||
'Staggered Menu',
|
||||
'Masonry',
|
||||
'Glass Surface',
|
||||
'Magic Bento',
|
||||
'Scroll Stack',
|
||||
'Profile Card',
|
||||
'Card Nav',
|
||||
'Pill Nav',
|
||||
'Dock',
|
||||
'Gooey Nav',
|
||||
'Bounce Cards',
|
||||
'Bubble Menu',
|
||||
'Pixel Card',
|
||||
'Card Nav',
|
||||
'Card Swap',
|
||||
'Carousel',
|
||||
'Spotlight Card',
|
||||
'Chroma Grid',
|
||||
'Circular Gallery',
|
||||
'Counter',
|
||||
'Decay Card',
|
||||
'Dock',
|
||||
'Dome Gallery',
|
||||
'Elastic Slider',
|
||||
'Flowing Menu',
|
||||
'Flying Posters',
|
||||
'Folder',
|
||||
'Card Swap',
|
||||
'Glass Icons',
|
||||
'Glass Surface',
|
||||
'Gooey Nav',
|
||||
'Infinite Menu',
|
||||
'Infinite Scroll',
|
||||
'Tilted Card',
|
||||
'Glass Icons',
|
||||
'Decay Card',
|
||||
'Dome Gallery',
|
||||
'Flowing Menu',
|
||||
'Elastic Slider',
|
||||
'Magic Bento',
|
||||
'Masonry',
|
||||
'Pixel Card',
|
||||
'Pill Nav',
|
||||
'Profile Card',
|
||||
'Rolling Gallery',
|
||||
'Scroll Stack',
|
||||
'Spotlight Card',
|
||||
'Stack',
|
||||
'Chroma Grid',
|
||||
'Staggered Menu',
|
||||
'Stepper',
|
||||
'Bounce Cards',
|
||||
'Counter',
|
||||
'Rolling Gallery'
|
||||
]
|
||||
'Tilted Card',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Backgrounds',
|
||||
subcategories: [
|
||||
'Color Bends',
|
||||
'Prism',
|
||||
'Aurora',
|
||||
'Balatro',
|
||||
'Ballpit',
|
||||
'Beams',
|
||||
'Pixel Blast',
|
||||
'Color Bends',
|
||||
'Dark Veil',
|
||||
'Dither',
|
||||
'Gradient Blinds',
|
||||
'Prismatic Burst',
|
||||
'Dot Grid',
|
||||
'Hyperspeed',
|
||||
'Faulty Terminal',
|
||||
'Plasma',
|
||||
'Galaxy',
|
||||
'Gradient Blinds',
|
||||
'Grid Distortion',
|
||||
'Grid Motion',
|
||||
'Hyperspeed',
|
||||
'Iridescence',
|
||||
'Letter Glitch',
|
||||
'Lightning',
|
||||
'Light Rays',
|
||||
'Liquid Chrome',
|
||||
'Liquid Ether',
|
||||
'Orb',
|
||||
'Particles',
|
||||
'Pixel Blast',
|
||||
'Plasma',
|
||||
'Prism',
|
||||
'Prismatic Burst',
|
||||
'Ripple Grid',
|
||||
'Silk',
|
||||
'Lightning',
|
||||
'Balatro',
|
||||
'Letter Glitch',
|
||||
'Particles',
|
||||
'Waves',
|
||||
'Squares',
|
||||
'Iridescence',
|
||||
'Threads',
|
||||
'Grid Motion',
|
||||
'Orb',
|
||||
'Ballpit',
|
||||
'Liquid Chrome',
|
||||
'Grid Distortion',
|
||||
'Galaxy',
|
||||
'Light Rays',
|
||||
]
|
||||
}
|
||||
'Waves',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
944
src/constants/Information.ts
Normal file
944
src/constants/Information.ts
Normal file
@@ -0,0 +1,944 @@
|
||||
export type ComponentMetadata = Record<
|
||||
string,
|
||||
{
|
||||
videoUrl: string;
|
||||
description: string;
|
||||
category: string;
|
||||
name: string;
|
||||
docsUrl: string;
|
||||
tags: string[];
|
||||
}
|
||||
>;
|
||||
|
||||
export const componentMetadata: ComponentMetadata = {
|
||||
//! Animations -------------------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
'Animations/AnimatedContent': {
|
||||
videoUrl: '/assets/videos/animatedcontent.webm',
|
||||
description:
|
||||
'Wrapper that animates any children on scroll or mount with configurable direction, distance, duration and easing.',
|
||||
category: 'Animations',
|
||||
name: 'AnimatedContent',
|
||||
docsUrl: 'https://vue-bits.dev/animations/animated-content',
|
||||
tags: []
|
||||
},
|
||||
'Animations/BlobCursor': {
|
||||
videoUrl: '/assets/videos/blobcursor.webm',
|
||||
description: 'Organic blob cursor that smoothly follows the pointer with inertia and elastic morphing.',
|
||||
category: 'Animations',
|
||||
name: 'BlobCursor',
|
||||
docsUrl: 'https://vue-bits.dev/animations/blob-cursor',
|
||||
tags: []
|
||||
},
|
||||
'Animations/ClickSpark': {
|
||||
videoUrl: '/assets/videos/clickspark.webm',
|
||||
description: 'Creates particle spark bursts at click position.',
|
||||
category: 'Animations',
|
||||
name: 'ClickSpark',
|
||||
docsUrl: 'https://vue-bits.dev/animations/click-spark',
|
||||
tags: []
|
||||
},
|
||||
'Animations/Crosshair': {
|
||||
videoUrl: '/assets/videos/crosshair.webm',
|
||||
description: 'Custom crosshair cursor with tracking, and link hover effects.',
|
||||
category: 'Animations',
|
||||
name: 'Crosshair',
|
||||
docsUrl: 'https://vue-bits.dev/animations/crosshair',
|
||||
tags: []
|
||||
},
|
||||
'Animations/Cubes': {
|
||||
videoUrl: '/assets/videos/cubes.webm',
|
||||
description: '3D rotating cube cluster. Supports auto-rotation or hover interaction.',
|
||||
category: 'Animations',
|
||||
name: 'Cubes',
|
||||
docsUrl: 'https://vue-bits.dev/animations/cubes',
|
||||
tags: []
|
||||
},
|
||||
'Animations/ElectricBorder': {
|
||||
videoUrl: '/assets/videos/electricborder.webm',
|
||||
description: 'Jittery electric energy border with animated arcs, glow and adjustable intensity.',
|
||||
category: 'Animations',
|
||||
name: 'ElectricBorder',
|
||||
docsUrl: 'https://vue-bits.dev/animations/electric-border',
|
||||
tags: []
|
||||
},
|
||||
'Animations/FadeContent': {
|
||||
videoUrl: '/assets/videos/fadecontent.webm',
|
||||
description: 'Simple directional fade / slide entrance wrapper with threshold-based activation.',
|
||||
category: 'Animations',
|
||||
name: 'FadeContent',
|
||||
docsUrl: 'https://vue-bits.dev/animations/fade-content',
|
||||
tags: []
|
||||
},
|
||||
'Animations/GlareHover': {
|
||||
videoUrl: '/assets/videos/glarehover.webm',
|
||||
description: 'Adds a realistic moving glare highlight on hover over any element.',
|
||||
category: 'Animations',
|
||||
name: 'GlareHover',
|
||||
docsUrl: 'https://vue-bits.dev/animations/glare-hover',
|
||||
tags: []
|
||||
},
|
||||
'Animations/GradualBlur': {
|
||||
videoUrl: '/assets/videos/gradualblur.webm',
|
||||
description: 'Progressively un-blurs content based on scroll or trigger creating a cinematic reveal.',
|
||||
category: 'Animations',
|
||||
name: 'GradualBlur',
|
||||
docsUrl: 'https://vue-bits.dev/animations/gradual-blur',
|
||||
tags: []
|
||||
},
|
||||
'Animations/GhostCursor': {
|
||||
videoUrl: '/assets/videos/ghostcursor.webm',
|
||||
description: 'Semi-transparent ghost cursor that smoothly follows the real cursor with a trailing effect.',
|
||||
category: 'Animations',
|
||||
name: 'GhostCursor',
|
||||
docsUrl: 'https://vue-bits.dev/animations/ghost-cursor',
|
||||
tags: []
|
||||
},
|
||||
'Animations/ImageTrail': {
|
||||
videoUrl: '/assets/videos/imagetrail.webm',
|
||||
description: 'Cursor-based image trail with several built-in variants.',
|
||||
category: 'Animations',
|
||||
name: 'ImageTrail',
|
||||
docsUrl: 'https://vue-bits.dev/animations/image-trail',
|
||||
tags: []
|
||||
},
|
||||
'Animations/LogoLoop': {
|
||||
videoUrl: '/assets/videos/logoloop.webm',
|
||||
description: 'Continuously looping marquee of brand or tech logos with seamless repeat and hover pause.',
|
||||
category: 'Animations',
|
||||
name: 'LogoLoop',
|
||||
docsUrl: 'https://vue-bits.dev/animations/logo-loop',
|
||||
tags: []
|
||||
},
|
||||
'Animations/Magnet': {
|
||||
videoUrl: '/assets/videos/magnet.webm',
|
||||
description: 'Elements magnetically ease toward the cursor then settle back with spring physics.',
|
||||
category: 'Animations',
|
||||
name: 'Magnet',
|
||||
docsUrl: 'https://vue-bits.dev/animations/magnet',
|
||||
tags: []
|
||||
},
|
||||
'Animations/MagnetLines': {
|
||||
videoUrl: '/assets/videos/magnetlines.webm',
|
||||
description: 'Animated field lines bend toward the cursor.',
|
||||
category: 'Animations',
|
||||
name: 'MagnetLines',
|
||||
docsUrl: 'https://vue-bits.dev/animations/magnet-lines',
|
||||
tags: []
|
||||
},
|
||||
'Animations/MetaBalls': {
|
||||
videoUrl: '/assets/videos/metaballs.webm',
|
||||
description: 'Liquid metaball blobs that merge and separate with smooth implicit surface animation.',
|
||||
category: 'Animations',
|
||||
name: 'MetaBalls',
|
||||
docsUrl: 'https://vue-bits.dev/animations/meta-balls',
|
||||
tags: []
|
||||
},
|
||||
'Animations/MetallicPaint': {
|
||||
videoUrl: '/assets/videos/metallicpaint.webm',
|
||||
description: 'Liquid metallic paint shader which can be applied to SVG elements.',
|
||||
category: 'Animations',
|
||||
name: 'MetallicPaint',
|
||||
docsUrl: 'https://vue-bits.dev/animations/metallic-paint',
|
||||
tags: []
|
||||
},
|
||||
'Animations/Noise': {
|
||||
videoUrl: '/assets/videos/noise.webm',
|
||||
description: 'Animated film grain / noise overlay adding subtle texture and motion.',
|
||||
category: 'Animations',
|
||||
name: 'Noise',
|
||||
docsUrl: 'https://vue-bits.dev/animations/noise',
|
||||
tags: []
|
||||
},
|
||||
'Animations/PixelTrail': {
|
||||
videoUrl: '/assets/videos/pixeltrail.webm',
|
||||
description: 'Pixelated cursor trail emitting fading squares with retro digital feel.',
|
||||
category: 'Animations',
|
||||
name: 'PixelTrail',
|
||||
docsUrl: 'https://vue-bits.dev/animations/pixel-trail',
|
||||
tags: []
|
||||
},
|
||||
'Animations/PixelTransition': {
|
||||
videoUrl: '/assets/videos/pixeltransition.webm',
|
||||
description: 'Pixel dissolve transition for content reveal on hover.',
|
||||
category: 'Animations',
|
||||
name: 'PixelTransition',
|
||||
docsUrl: 'https://vue-bits.dev/animations/pixel-transition',
|
||||
tags: []
|
||||
},
|
||||
'Animations/Ribbons': {
|
||||
videoUrl: '/assets/videos/ribbons.webm',
|
||||
description: 'Flowing responsive ribbons/cursor trail driven by physics and pointer motion.',
|
||||
category: 'Animations',
|
||||
name: 'Ribbons',
|
||||
docsUrl: 'https://vue-bits.dev/animations/ribbons',
|
||||
tags: []
|
||||
},
|
||||
'Animations/ShapeBlur': {
|
||||
videoUrl: '/assets/videos/shapeblur.webm',
|
||||
description: 'Morphing blurred geometric shape. The effect occurs on hover.',
|
||||
category: 'Animations',
|
||||
name: 'ShapeBlur',
|
||||
docsUrl: 'https://vue-bits.dev/animations/shape-blur',
|
||||
tags: []
|
||||
},
|
||||
'Animations/SplashCursor': {
|
||||
videoUrl: '/assets/videos/splashcursor.webm',
|
||||
description: 'Liquid splash burst at cursor with curling ripples and waves.',
|
||||
category: 'Animations',
|
||||
name: 'SplashCursor',
|
||||
docsUrl: 'https://vue-bits.dev/animations/splash-cursor',
|
||||
tags: []
|
||||
},
|
||||
'Animations/StarBorder': {
|
||||
videoUrl: '/assets/videos/starborder.webm',
|
||||
description: 'Animated star / sparkle border orbiting content with twinkle pulses.',
|
||||
category: 'Animations',
|
||||
name: 'StarBorder',
|
||||
docsUrl: 'https://vue-bits.dev/animations/star-border',
|
||||
tags: []
|
||||
},
|
||||
'Animations/StickerPeel': {
|
||||
videoUrl: '/assets/videos/stickerpeel.webm',
|
||||
description: 'Sticker corner lift + peel interaction using 3D transform and shadow depth.',
|
||||
category: 'Animations',
|
||||
name: 'StickerPeel',
|
||||
docsUrl: 'https://vue-bits.dev/animations/sticker-peel',
|
||||
tags: []
|
||||
},
|
||||
'Animations/TargetCursor': {
|
||||
videoUrl: '/assets/videos/targetcursor.webm',
|
||||
description: 'A cursor follow animation with 4 corners that lock onto targets.',
|
||||
category: 'Animations',
|
||||
name: 'TargetCursor',
|
||||
docsUrl: 'https://vue-bits.dev/animations/target-cursor',
|
||||
tags: []
|
||||
},
|
||||
'Animations/LaserFlow': {
|
||||
videoUrl: '/assets/videos/laserflow.webm',
|
||||
description: 'Dynamic laser light that flows onto a surface, customizable effect.',
|
||||
category: 'Animations',
|
||||
name: 'LaserFlow',
|
||||
docsUrl: 'https://vue-bits.dev/animations/laser-flow',
|
||||
tags: []
|
||||
},
|
||||
|
||||
//! Text Animations -------------------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
'TextAnimations/AsciiText': {
|
||||
videoUrl: '/assets/videos/asciitext.webm',
|
||||
description: 'Renders text with an animated ASCII background for a retro feel.',
|
||||
category: 'TextAnimations',
|
||||
name: 'ASCIIText',
|
||||
docsUrl: 'https://vue-bits.dev/text-animations/ascii-text',
|
||||
tags: []
|
||||
},
|
||||
'TextAnimations/BlurText': {
|
||||
videoUrl: '/assets/videos/blurtext.webm',
|
||||
description: 'Text starts blurred then crisply resolves for a soft-focus reveal effect.',
|
||||
category: 'TextAnimations',
|
||||
name: 'BlurText',
|
||||
docsUrl: 'https://vue-bits.dev/text-animations/blur-text',
|
||||
tags: []
|
||||
},
|
||||
'TextAnimations/CircularText': {
|
||||
videoUrl: '/assets/videos/circulartext.webm',
|
||||
description: 'Layouts characters around a circle with optional rotation animation.',
|
||||
category: 'TextAnimations',
|
||||
name: 'CircularText',
|
||||
docsUrl: 'https://vue-bits.dev/text-animations/circular-text',
|
||||
tags: []
|
||||
},
|
||||
'TextAnimations/CountUp': {
|
||||
videoUrl: '/assets/videos/countup.webm',
|
||||
description: 'Animated number counter supporting formatting and decimals.',
|
||||
category: 'TextAnimations',
|
||||
name: 'CountUp',
|
||||
docsUrl: 'https://vue-bits.dev/text-animations/count-up',
|
||||
tags: []
|
||||
},
|
||||
'TextAnimations/CurvedLoop': {
|
||||
videoUrl: '/assets/videos/curvedloop.webm',
|
||||
description: 'Flowing looping text path along a customizable curve with drag interaction.',
|
||||
category: 'TextAnimations',
|
||||
name: 'CurvedLoop',
|
||||
docsUrl: 'https://vue-bits.dev/text-animations/curved-loop',
|
||||
tags: []
|
||||
},
|
||||
'TextAnimations/DecryptedText': {
|
||||
videoUrl: '/assets/videos/decryptedtext.webm',
|
||||
description: 'Hacker-style decryption cycling random glyphs until resolving to real text.',
|
||||
category: 'TextAnimations',
|
||||
name: 'DecryptedText',
|
||||
docsUrl: 'https://vue-bits.dev/text-animations/decrypted-text',
|
||||
tags: []
|
||||
},
|
||||
'TextAnimations/FallingText': {
|
||||
videoUrl: '/assets/videos/fallingtext.webm',
|
||||
description: 'Characters fall with gravity + bounce creating a playful entrance.',
|
||||
category: 'TextAnimations',
|
||||
name: 'FallingText',
|
||||
docsUrl: 'https://vue-bits.dev/text-animations/falling-text',
|
||||
tags: []
|
||||
},
|
||||
'TextAnimations/FuzzyText': {
|
||||
videoUrl: '/assets/videos/fuzzytext.webm',
|
||||
description: 'Vibrating fuzzy text with controllable hover intensity.',
|
||||
category: 'TextAnimations',
|
||||
name: 'FuzzyText',
|
||||
docsUrl: 'https://vue-bits.dev/text-animations/fuzzy-text',
|
||||
tags: []
|
||||
},
|
||||
'TextAnimations/GlitchText': {
|
||||
videoUrl: '/assets/videos/glitchtext.webm',
|
||||
description: 'RGB split and distortion glitch effect with jitter effects.',
|
||||
category: 'TextAnimations',
|
||||
name: 'GlitchText',
|
||||
docsUrl: 'https://vue-bits.dev/text-animations/glitch-text',
|
||||
tags: []
|
||||
},
|
||||
'TextAnimations/GradientText': {
|
||||
videoUrl: '/assets/videos/gradienttext.webm',
|
||||
description: 'Animated gradient sweep across live text with speed and color control.',
|
||||
category: 'TextAnimations',
|
||||
name: 'GradientText',
|
||||
docsUrl: 'https://vue-bits.dev/text-animations/gradient-text',
|
||||
tags: []
|
||||
},
|
||||
'TextAnimations/RotatingText': {
|
||||
videoUrl: '/assets/videos/rotatingtext.webm',
|
||||
description: 'Cycles through multiple phrases with 3D rotate / flip transitions.',
|
||||
category: 'TextAnimations',
|
||||
name: 'RotatingText',
|
||||
docsUrl: 'https://vue-bits.dev/text-animations/rotating-text',
|
||||
tags: []
|
||||
},
|
||||
'TextAnimations/ScrambledText': {
|
||||
videoUrl: '/assets/videos/scrambledtext.webm',
|
||||
description: 'Detects cursor position and applies a distortion effect to text.',
|
||||
category: 'TextAnimations',
|
||||
name: 'ScrambledText',
|
||||
docsUrl: 'https://vue-bits.dev/text-animations/scrambled-text',
|
||||
tags: []
|
||||
},
|
||||
'TextAnimations/ScrollFloat': {
|
||||
videoUrl: '/assets/videos/scrollfloat.webm',
|
||||
description: 'Text gently floats / parallax shifts on scroll.',
|
||||
category: 'TextAnimations',
|
||||
name: 'ScrollFloat',
|
||||
docsUrl: 'https://vue-bits.dev/text-animations/scroll-float',
|
||||
tags: []
|
||||
},
|
||||
'TextAnimations/ScrollReveal': {
|
||||
videoUrl: '/assets/videos/scrollreveal.webm',
|
||||
description: 'Text gently unblurs and reveals on scroll.',
|
||||
category: 'TextAnimations',
|
||||
name: 'ScrollReveal',
|
||||
docsUrl: 'https://vue-bits.dev/text-animations/scroll-reveal',
|
||||
tags: []
|
||||
},
|
||||
'TextAnimations/ScrollVelocity': {
|
||||
videoUrl: '/assets/videos/scrollvelocity.webm',
|
||||
description: "Text marquee animatio - speed and distortion scale with user's scroll velocity.",
|
||||
category: 'TextAnimations',
|
||||
name: 'ScrollVelocity',
|
||||
docsUrl: 'https://vue-bits.dev/text-animations/scroll-velocity',
|
||||
tags: []
|
||||
},
|
||||
'TextAnimations/ShinyText': {
|
||||
videoUrl: '/assets/videos/shinytext.webm',
|
||||
description: 'Metallic sheen sweeps across text producing a reflective highlight.',
|
||||
category: 'TextAnimations',
|
||||
name: 'ShinyText',
|
||||
docsUrl: 'https://vue-bits.dev/text-animations/shiny-text',
|
||||
tags: []
|
||||
},
|
||||
'TextAnimations/SplitText': {
|
||||
videoUrl: '/assets/videos/splittext.webm',
|
||||
description: 'Splits text into characters / words for staggered entrance animation.',
|
||||
category: 'TextAnimations',
|
||||
name: 'SplitText',
|
||||
docsUrl: 'https://vue-bits.dev/text-animations/split-text',
|
||||
tags: []
|
||||
},
|
||||
'TextAnimations/TextCursor': {
|
||||
videoUrl: '/assets/videos/textcursor.webm',
|
||||
description: 'Make any text element follow your cursor, leaving a trail of copies behind it.',
|
||||
category: 'TextAnimations',
|
||||
name: 'TextCursor',
|
||||
docsUrl: 'https://vue-bits.dev/text-animations/text-cursor',
|
||||
tags: []
|
||||
},
|
||||
'TextAnimations/TextPressure': {
|
||||
videoUrl: '/assets/videos/textpressure.webm',
|
||||
description: 'Characters scale / warp interactively based on pointer pressure zone.',
|
||||
category: 'TextAnimations',
|
||||
name: 'TextPressure',
|
||||
docsUrl: 'https://vue-bits.dev/text-animations/text-pressure',
|
||||
tags: []
|
||||
},
|
||||
'TextAnimations/TextType': {
|
||||
videoUrl: '/assets/videos/texttype.webm',
|
||||
description: 'Typewriter effect with blinking cursor and adjustable typing cadence.',
|
||||
category: 'TextAnimations',
|
||||
name: 'TextType',
|
||||
docsUrl: 'https://vue-bits.dev/text-animations/text-type',
|
||||
tags: []
|
||||
},
|
||||
'TextAnimations/TrueFocus': {
|
||||
videoUrl: '/assets/videos/truefocus.webm',
|
||||
description: 'Applies dynamic blur / clarity based over a series of words in order.',
|
||||
category: 'TextAnimations',
|
||||
name: 'TrueFocus',
|
||||
docsUrl: 'https://vue-bits.dev/text-animations/true-focus',
|
||||
tags: []
|
||||
},
|
||||
'TextAnimations/VariableProximity': {
|
||||
videoUrl: '/assets/videos/variableproximity.webm',
|
||||
description: 'Letter styling changes continuously with pointer distance mapping.',
|
||||
category: 'TextAnimations',
|
||||
name: 'VariableProximity',
|
||||
docsUrl: 'https://vue-bits.dev/text-animations/variable-proximity',
|
||||
tags: []
|
||||
},
|
||||
'TextAnimations/Shuffle': {
|
||||
videoUrl: '/assets/videos/shuffle.webm',
|
||||
description: 'Animated text reveal where characters shuffle before settling.',
|
||||
category: 'TextAnimations',
|
||||
name: 'Shuffle',
|
||||
docsUrl: 'https://vue-bits.dev/text-animations/shuffle',
|
||||
tags: []
|
||||
},
|
||||
|
||||
//! Components -------------------------------------------------------------------------------------------------------------------------------
|
||||
'Components/AnimatedList': {
|
||||
videoUrl: '/assets/videos/animatedlist.webm',
|
||||
description: 'List items enter with staggered motion variants for polished reveals.',
|
||||
category: 'Components',
|
||||
name: 'AnimatedList',
|
||||
docsUrl: 'https://vue-bits.dev/components/animated-list',
|
||||
tags: []
|
||||
},
|
||||
'Components/BounceCards': {
|
||||
videoUrl: '/assets/videos/bouncecards.webm',
|
||||
description: 'Cards bounce that bounce in on mount.',
|
||||
category: 'Components',
|
||||
name: 'BounceCards',
|
||||
docsUrl: 'https://vue-bits.dev/components/bounce-cards',
|
||||
tags: []
|
||||
},
|
||||
'Components/BubbleMenu': {
|
||||
videoUrl: '/assets/videos/bubblemenu.webm',
|
||||
description: 'Floating circular expanding menu with staggered item reveal.',
|
||||
category: 'Components',
|
||||
name: 'BubbleMenu',
|
||||
docsUrl: 'https://vue-bits.dev/components/bubble-menu',
|
||||
tags: []
|
||||
},
|
||||
'Components/CardNav': {
|
||||
videoUrl: '/assets/videos/cardnav.webm',
|
||||
description: 'Expandable navigation bar with card panels revealing nested links.',
|
||||
category: 'Components',
|
||||
name: 'CardNav',
|
||||
docsUrl: 'https://vue-bits.dev/components/card-nav',
|
||||
tags: []
|
||||
},
|
||||
'Components/CardSwap': {
|
||||
videoUrl: '/assets/videos/cardswap.webm',
|
||||
description: 'Cards animate position swapping with smooth layout transitions.',
|
||||
category: 'Components',
|
||||
name: 'CardSwap',
|
||||
docsUrl: 'https://vue-bits.dev/components/card-swap',
|
||||
tags: []
|
||||
},
|
||||
'Components/Carousel': {
|
||||
videoUrl: '/assets/videos/carousel.webm',
|
||||
description: 'Responsive carousel with touch gestures, looping and transitions.',
|
||||
category: 'Components',
|
||||
name: 'Carousel',
|
||||
docsUrl: 'https://vue-bits.dev/components/carousel',
|
||||
tags: []
|
||||
},
|
||||
'Components/ChromaGrid': {
|
||||
videoUrl: '/assets/videos/chromagrid.webm',
|
||||
description: 'A responsive grid of grayscale tiles. Hovering the grid reaveals their colors.',
|
||||
category: 'Components',
|
||||
name: 'ChromaGrid',
|
||||
docsUrl: 'https://vue-bits.dev/components/chroma-grid',
|
||||
tags: []
|
||||
},
|
||||
'Components/CircularGallery': {
|
||||
videoUrl: '/assets/videos/circulargallery.webm',
|
||||
description: 'Circular orbit gallery rotating images.',
|
||||
category: 'Components',
|
||||
name: 'CircularGallery',
|
||||
docsUrl: 'https://vue-bits.dev/components/circular-gallery',
|
||||
tags: []
|
||||
},
|
||||
'Components/Counter': {
|
||||
videoUrl: '/assets/videos/counter.webm',
|
||||
description: 'Flexible animated counter supporting increments + easing.',
|
||||
category: 'Components',
|
||||
name: 'Counter',
|
||||
docsUrl: 'https://vue-bits.dev/components/counter',
|
||||
tags: []
|
||||
},
|
||||
'Components/DecayCard': {
|
||||
videoUrl: '/assets/videos/decaycard.webm',
|
||||
description: 'Hover parallax effect that disintegrates the content of a card.',
|
||||
category: 'Components',
|
||||
name: 'DecayCard',
|
||||
docsUrl: 'https://vue-bits.dev/components/decay-card',
|
||||
tags: []
|
||||
},
|
||||
'Components/Dock': {
|
||||
videoUrl: '/assets/videos/dock.webm',
|
||||
description: 'macOS style magnifying dock with proximity scaling of icons.',
|
||||
category: 'Components',
|
||||
name: 'Dock',
|
||||
docsUrl: 'https://vue-bits.dev/components/dock',
|
||||
tags: []
|
||||
},
|
||||
'Components/DomeGallery': {
|
||||
videoUrl: '/assets/videos/domegallery.webm',
|
||||
description: 'Immersive 3D dome gallery projecting images on a hemispheric surface.',
|
||||
category: 'Components',
|
||||
name: 'DomeGallery',
|
||||
docsUrl: 'https://vue-bits.dev/components/dome-gallery',
|
||||
tags: []
|
||||
},
|
||||
'Components/ElasticSlider': {
|
||||
videoUrl: '/assets/videos/elasticslider.webm',
|
||||
description: 'Slider handle stretches elastically then snaps with spring physics.',
|
||||
category: 'Components',
|
||||
name: 'ElasticSlider',
|
||||
docsUrl: 'https://vue-bits.dev/components/elastic-slider',
|
||||
tags: []
|
||||
},
|
||||
'Components/FlowingMenu': {
|
||||
videoUrl: '/assets/videos/flowingmenu.webm',
|
||||
description: 'Liquid flowing active indicator glides between menu items.',
|
||||
category: 'Components',
|
||||
name: 'FlowingMenu',
|
||||
docsUrl: 'https://vue-bits.dev/components/flowing-menu',
|
||||
tags: []
|
||||
},
|
||||
'Components/FluidGlass': {
|
||||
videoUrl: '/assets/videos/fluidglass.webm',
|
||||
description: 'Glassmorphism container with animated liquid distortion refraction.',
|
||||
category: 'Components',
|
||||
name: 'FluidGlass',
|
||||
docsUrl: 'https://vue-bits.dev/components/fluid-glass',
|
||||
tags: []
|
||||
},
|
||||
'Components/FlyingPosters': {
|
||||
videoUrl: '/assets/videos/flyingposters.webm',
|
||||
description: '3D posters rotate on scroll infinitely.',
|
||||
category: 'Components',
|
||||
name: 'FlyingPosters',
|
||||
docsUrl: 'https://vue-bits.dev/components/flying-posters',
|
||||
tags: []
|
||||
},
|
||||
'Components/Folder': {
|
||||
videoUrl: '/assets/videos/folder.webm',
|
||||
description: 'Interactive folder opens to reveal nested content smooth motion.',
|
||||
category: 'Components',
|
||||
name: 'Folder',
|
||||
docsUrl: 'https://vue-bits.dev/components/folder',
|
||||
tags: []
|
||||
},
|
||||
'Components/GlassIcons': {
|
||||
videoUrl: '/assets/videos/glassicons.webm',
|
||||
description: 'Icon set styled with frosted glass blur.',
|
||||
category: 'Components',
|
||||
name: 'GlassIcons',
|
||||
docsUrl: 'https://vue-bits.dev/components/glass-icons',
|
||||
tags: []
|
||||
},
|
||||
'Components/GlassSurface': {
|
||||
videoUrl: '/assets/videos/glasssurface.webm',
|
||||
description: 'Advanced Apple-style glass surface with real-time distortion + lighting.',
|
||||
category: 'Components',
|
||||
name: 'GlassSurface',
|
||||
docsUrl: 'https://vue-bits.dev/components/glass-surface',
|
||||
tags: []
|
||||
},
|
||||
'Components/GooeyNav': {
|
||||
videoUrl: '/assets/videos/gooeynav.webm',
|
||||
description: 'Navigation indicator morphs with gooey blob transitions between items.',
|
||||
category: 'Components',
|
||||
name: 'GooeyNav',
|
||||
docsUrl: 'https://vue-bits.dev/components/gooey-nav',
|
||||
tags: []
|
||||
},
|
||||
'Components/InfiniteMenu': {
|
||||
videoUrl: '/assets/videos/infinitemenu.webm',
|
||||
description: 'Horizontally looping menu effect that scrolls endlessly with seamless wrap.',
|
||||
category: 'Components',
|
||||
name: 'InfiniteMenu',
|
||||
docsUrl: 'https://vue-bits.dev/components/infinite-menu',
|
||||
tags: []
|
||||
},
|
||||
'Components/InfiniteScroll': {
|
||||
videoUrl: '/assets/videos/infinitescroll.webm',
|
||||
description: 'Infinite scrolling container auto-loads content near viewport end.',
|
||||
category: 'Components',
|
||||
name: 'InfiniteScroll',
|
||||
docsUrl: 'https://vue-bits.dev/components/infinite-scroll',
|
||||
tags: []
|
||||
},
|
||||
'Components/Lanyard': {
|
||||
videoUrl: '/assets/videos/lanyard.webm',
|
||||
description: 'Swinging 3D lanyard / badge card with realistic inertial motion.',
|
||||
category: 'Components',
|
||||
name: 'Lanyard',
|
||||
docsUrl: 'https://vue-bits.dev/components/lanyard',
|
||||
tags: []
|
||||
},
|
||||
'Components/MagicBento': {
|
||||
videoUrl: '/assets/videos/magicbento.webm',
|
||||
description: 'Interactive bento grid tiles expand + animate with various options.',
|
||||
category: 'Components',
|
||||
name: 'MagicBento',
|
||||
docsUrl: 'https://vue-bits.dev/components/magic-bento',
|
||||
tags: []
|
||||
},
|
||||
'Components/Masonry': {
|
||||
videoUrl: '/assets/videos/masonry.webm',
|
||||
description: 'Responsive masonry layout with animated reflow + gaps optimization.',
|
||||
category: 'Components',
|
||||
name: 'Masonry',
|
||||
docsUrl: 'https://vue-bits.dev/components/masonry',
|
||||
tags: []
|
||||
},
|
||||
'Components/ModelViewer': {
|
||||
videoUrl: '/assets/videos/modelviewer.webm',
|
||||
description: 'Three.js model viewer with orbit controls and lighting presets.',
|
||||
category: 'Components',
|
||||
name: 'ModelViewer',
|
||||
docsUrl: 'https://vue-bits.dev/components/model-viewer',
|
||||
tags: []
|
||||
},
|
||||
'Components/PillNav': {
|
||||
videoUrl: '/assets/videos/pillnav.webm',
|
||||
description: 'Minimal pill nav with sliding active highlight + smooth easing.',
|
||||
category: 'Components',
|
||||
name: 'PillNav',
|
||||
docsUrl: 'https://vue-bits.dev/components/pill-nav',
|
||||
tags: []
|
||||
},
|
||||
'Components/PixelCard': {
|
||||
videoUrl: '/assets/videos/pixelcard.webm',
|
||||
description: 'Card content revealed through pixel expansion transition.',
|
||||
category: 'Components',
|
||||
name: 'PixelCard',
|
||||
docsUrl: 'https://vue-bits.dev/components/pixel-card',
|
||||
tags: []
|
||||
},
|
||||
'Components/ProfileCard': {
|
||||
videoUrl: '/assets/videos/profilecard.webm',
|
||||
description: 'Animated profile card glare with 3D hover effect.',
|
||||
category: 'Components',
|
||||
name: 'ProfileCard',
|
||||
docsUrl: 'https://vue-bits.dev/components/profile-card',
|
||||
tags: []
|
||||
},
|
||||
'Components/ScrollStack': {
|
||||
videoUrl: '/assets/videos/scrollstack.webm',
|
||||
description: 'Overlapping card stack reveals on scroll with depth layering.',
|
||||
category: 'Components',
|
||||
name: 'ScrollStack',
|
||||
docsUrl: 'https://vue-bits.dev/components/scroll-stack',
|
||||
tags: []
|
||||
},
|
||||
'Components/SpotlightCard': {
|
||||
videoUrl: '/assets/videos/spotlightcard.webm',
|
||||
description: 'Dynamic spotlight follows cursor casting gradient illumination.',
|
||||
category: 'Components',
|
||||
name: 'SpotlightCard',
|
||||
docsUrl: 'https://vue-bits.dev/components/spotlight-card',
|
||||
tags: []
|
||||
},
|
||||
'Components/Stack': {
|
||||
videoUrl: '/assets/videos/stack.webm',
|
||||
description: 'Layered stack with swipe animations and smooth transitions.',
|
||||
category: 'Components',
|
||||
name: 'Stack',
|
||||
docsUrl: 'https://vue-bits.dev/components/stack',
|
||||
tags: []
|
||||
},
|
||||
'Components/Stepper': {
|
||||
videoUrl: '/assets/videos/stepper.webm',
|
||||
description: 'Animated multi-step progress indicator with active state transitions.',
|
||||
category: 'Components',
|
||||
name: 'Stepper',
|
||||
docsUrl: 'https://vue-bits.dev/components/stepper',
|
||||
tags: []
|
||||
},
|
||||
'Components/TiltedCard': {
|
||||
videoUrl: '/assets/videos/tiltedcard.webm',
|
||||
description: '3D perspective tilt card reacting to pointer.',
|
||||
category: 'Components',
|
||||
name: 'TiltedCard',
|
||||
docsUrl: 'https://vue-bits.dev/components/tilted-card',
|
||||
tags: []
|
||||
},
|
||||
'Components/StaggeredMenu': {
|
||||
videoUrl: '/assets/videos/staggeredmenu.webm',
|
||||
description: 'Menu with staggered item animations and smooth transitions on open/close.',
|
||||
category: 'Components',
|
||||
name: 'StaggeredMenu',
|
||||
docsUrl: 'https://vue-bits.dev/components/staggered-menu',
|
||||
tags: []
|
||||
},
|
||||
|
||||
//! Backgrounds -------------------------------------------------------------------------------------------------------------------------------
|
||||
'Backgrounds/Aurora': {
|
||||
videoUrl: '/assets/videos/aurora.webm',
|
||||
description: 'Flowing aurora gradient background.',
|
||||
category: 'Backgrounds',
|
||||
name: 'Aurora',
|
||||
docsUrl: 'https://vue-bits.dev/backgrounds/aurora',
|
||||
tags: []
|
||||
},
|
||||
'Backgrounds/Balatro': {
|
||||
videoUrl: '/assets/videos/balatro.webm',
|
||||
description: 'The balatro shader, fully customizalbe and interactive.',
|
||||
category: 'Backgrounds',
|
||||
name: 'Balatro',
|
||||
docsUrl: 'https://vue-bits.dev/backgrounds/balatro',
|
||||
tags: []
|
||||
},
|
||||
'Backgrounds/Ballpit': {
|
||||
videoUrl: '/assets/videos/ballpit.webm',
|
||||
description: 'Physics ball pit simulation with bouncing colorful spheres.',
|
||||
category: 'Backgrounds',
|
||||
name: 'Ballpit',
|
||||
docsUrl: 'https://vue-bits.dev/backgrounds/ballpit',
|
||||
tags: []
|
||||
},
|
||||
'Backgrounds/Beams': {
|
||||
videoUrl: '/assets/videos/beams.webm',
|
||||
description: 'Crossing animated ribbons with customizable properties.',
|
||||
category: 'Backgrounds',
|
||||
name: 'Beams',
|
||||
docsUrl: 'https://vue-bits.dev/backgrounds/beams',
|
||||
tags: []
|
||||
},
|
||||
'Backgrounds/ColorBends': {
|
||||
videoUrl: '/assets/videos/colorbends.webm',
|
||||
description: 'Vibrant color bends with smooth flowing animation.',
|
||||
category: 'Backgrounds',
|
||||
name: 'ColorBends',
|
||||
docsUrl: 'https://vue-bits.dev/backgrounds/color-bends',
|
||||
tags: []
|
||||
},
|
||||
'Backgrounds/DarkVeil': {
|
||||
videoUrl: '/assets/videos/darkveil.webm',
|
||||
description: 'Subtle dark background with a smooth animation and postprocessing.',
|
||||
category: 'Backgrounds',
|
||||
name: 'DarkVeil',
|
||||
docsUrl: 'https://vue-bits.dev/backgrounds/dark-veil',
|
||||
tags: []
|
||||
},
|
||||
'Backgrounds/Dither': {
|
||||
videoUrl: '/assets/videos/dither.webm',
|
||||
description: 'Retro dithered noise shader background.',
|
||||
category: 'Backgrounds',
|
||||
name: 'Dither',
|
||||
docsUrl: 'https://vue-bits.dev/backgrounds/dither',
|
||||
tags: []
|
||||
},
|
||||
'Backgrounds/DotGrid': {
|
||||
videoUrl: '/assets/videos/dotgrid.webm',
|
||||
description: 'Animated dot grid with cursor interactions.',
|
||||
category: 'Backgrounds',
|
||||
name: 'DotGrid',
|
||||
docsUrl: 'https://vue-bits.dev/backgrounds/dot-grid',
|
||||
tags: []
|
||||
},
|
||||
'Backgrounds/FaultyTerminal': {
|
||||
videoUrl: '/assets/videos/faultyterminal.webm',
|
||||
description: 'Terminal CRT scanline squares effect with flicker + noise.',
|
||||
category: 'Backgrounds',
|
||||
name: 'FaultyTerminal',
|
||||
docsUrl: 'https://vue-bits.dev/backgrounds/faulty-terminal',
|
||||
tags: []
|
||||
},
|
||||
'Backgrounds/Galaxy': {
|
||||
videoUrl: '/assets/videos/galaxy.webm',
|
||||
description: 'Parallax realistic starfield with pointer interactions.',
|
||||
category: 'Backgrounds',
|
||||
name: 'Galaxy',
|
||||
docsUrl: 'https://vue-bits.dev/backgrounds/galaxy',
|
||||
tags: []
|
||||
},
|
||||
'Backgrounds/GradientBlinds': {
|
||||
videoUrl: '/assets/videos/gradientblinds.webm',
|
||||
description: 'Layered gradient blinds with spotlight and noise distortion.',
|
||||
category: 'Backgrounds',
|
||||
name: 'GradientBlinds',
|
||||
docsUrl: 'https://vue-bits.dev/backgrounds/gradient-blinds',
|
||||
tags: []
|
||||
},
|
||||
'Backgrounds/GridDistortion': {
|
||||
videoUrl: '/assets/videos/griddistortion.webm',
|
||||
description: 'Warped grid mesh distorts smoothly reacting to cursor.',
|
||||
category: 'Backgrounds',
|
||||
name: 'GridDistortion',
|
||||
docsUrl: 'https://vue-bits.dev/backgrounds/grid-distortion',
|
||||
tags: []
|
||||
},
|
||||
'Backgrounds/GridMotion': {
|
||||
videoUrl: '/assets/videos/gridmotion.webm',
|
||||
description: 'Perspective moving grid lines based on cusror position.',
|
||||
category: 'Backgrounds',
|
||||
name: 'GridMotion',
|
||||
docsUrl: 'https://vue-bits.dev/backgrounds/grid-motion',
|
||||
tags: []
|
||||
},
|
||||
'Backgrounds/Hyperspeed': {
|
||||
videoUrl: '/assets/videos/hyperspeed.webm',
|
||||
description: 'Animated lines continously moving to simulate hyperspace travel on click hold.',
|
||||
category: 'Backgrounds',
|
||||
name: 'Hyperspeed',
|
||||
docsUrl: 'https://vue-bits.dev/backgrounds/hyperspeed',
|
||||
tags: []
|
||||
},
|
||||
'Backgrounds/Iridescence': {
|
||||
videoUrl: '/assets/videos/iridescence.webm',
|
||||
description: 'Slick iridescent shader with shifting waves.',
|
||||
category: 'Backgrounds',
|
||||
name: 'Iridescence',
|
||||
docsUrl: 'https://vue-bits.dev/backgrounds/iridescence',
|
||||
tags: []
|
||||
},
|
||||
'Backgrounds/LetterGlitch': {
|
||||
videoUrl: '/assets/videos/letterglitch.webm',
|
||||
description: 'Matrix style letter animation.',
|
||||
category: 'Backgrounds',
|
||||
name: 'LetterGlitch',
|
||||
docsUrl: 'https://vue-bits.dev/backgrounds/letter-glitch',
|
||||
tags: []
|
||||
},
|
||||
'Backgrounds/LightRays': {
|
||||
videoUrl: '/assets/videos/lightrays.webm',
|
||||
description: 'Volumetric light rays/beams with customizable direction.',
|
||||
category: 'Backgrounds',
|
||||
name: 'LightRays',
|
||||
docsUrl: 'https://vue-bits.dev/backgrounds/light-rays',
|
||||
tags: []
|
||||
},
|
||||
'Backgrounds/Lightning': {
|
||||
videoUrl: '/assets/videos/lightning.webm',
|
||||
description: 'Procedural lightning bolts with branching and glow flicker.',
|
||||
category: 'Backgrounds',
|
||||
name: 'Lightning',
|
||||
docsUrl: 'https://vue-bits.dev/backgrounds/lightning',
|
||||
tags: []
|
||||
},
|
||||
'Backgrounds/LiquidChrome': {
|
||||
videoUrl: '/assets/videos/liquidchrome.webm',
|
||||
description: 'Liquid metallic chrome shader with flowing reflective surface.',
|
||||
category: 'Backgrounds',
|
||||
name: 'LiquidChrome',
|
||||
docsUrl: 'https://vue-bits.dev/backgrounds/liquid-chrome',
|
||||
tags: []
|
||||
},
|
||||
'Backgrounds/Orb': {
|
||||
videoUrl: '/assets/videos/orb.webm',
|
||||
description: 'Floating energy orb with customizable hover effect.',
|
||||
category: 'Backgrounds',
|
||||
name: 'Orb',
|
||||
docsUrl: 'https://vue-bits.dev/backgrounds/orb',
|
||||
tags: []
|
||||
},
|
||||
'Backgrounds/Particles': {
|
||||
videoUrl: '/assets/videos/particles.webm',
|
||||
description: 'Configurable particle system.',
|
||||
category: 'Backgrounds',
|
||||
name: 'Particles',
|
||||
docsUrl: 'https://vue-bits.dev/backgrounds/particles',
|
||||
tags: []
|
||||
},
|
||||
'Backgrounds/PixelBlast': {
|
||||
videoUrl: '/assets/videos/pixelblast.webm',
|
||||
description: 'Exploding pixel particle bursts with optional liquid postprocessing.',
|
||||
category: 'Backgrounds',
|
||||
name: 'PixelBlast',
|
||||
docsUrl: 'https://vue-bits.dev/backgrounds/pixel-blast',
|
||||
tags: []
|
||||
},
|
||||
'Backgrounds/Plasma': {
|
||||
videoUrl: '/assets/videos/plasma.webm',
|
||||
description: 'Organic plasma gradients swirl + morph with smooth turbulence.',
|
||||
category: 'Backgrounds',
|
||||
name: 'Plasma',
|
||||
docsUrl: 'https://vue-bits.dev/backgrounds/plasma',
|
||||
tags: []
|
||||
},
|
||||
'Backgrounds/Prism': {
|
||||
videoUrl: '/assets/videos/prism.webm',
|
||||
description: 'Rotating prism with configurable intensity, size, and colors.',
|
||||
category: 'Backgrounds',
|
||||
name: 'Prism',
|
||||
docsUrl: 'https://vue-bits.dev/backgrounds/prism',
|
||||
tags: []
|
||||
},
|
||||
'Backgrounds/PrismaticBurst': {
|
||||
videoUrl: '/assets/videos/prismaticburst.webm',
|
||||
description: 'Burst of light rays with controllable color, distortion, amount.',
|
||||
category: 'Backgrounds',
|
||||
name: 'PrismaticBurst',
|
||||
docsUrl: 'https://vue-bits.dev/backgrounds/prismatic-burst',
|
||||
tags: []
|
||||
},
|
||||
'Backgrounds/RippleGrid': {
|
||||
videoUrl: '/assets/videos/ripplegrid.webm',
|
||||
description: 'A grid that continously animates with a ripple effect.',
|
||||
category: 'Backgrounds',
|
||||
name: 'RippleGrid',
|
||||
docsUrl: 'https://vue-bits.dev/backgrounds/ripple-grid',
|
||||
tags: []
|
||||
},
|
||||
'Backgrounds/Silk': {
|
||||
videoUrl: '/assets/videos/silk.webm',
|
||||
description: 'Smooth waves background with soft lighting.',
|
||||
category: 'Backgrounds',
|
||||
name: 'Silk',
|
||||
docsUrl: 'https://vue-bits.dev/backgrounds/silk',
|
||||
tags: []
|
||||
},
|
||||
'Backgrounds/Squares': {
|
||||
videoUrl: '/assets/videos/squares.webm',
|
||||
description: 'Animated squares with scaling + direction customization.',
|
||||
category: 'Backgrounds',
|
||||
name: 'Squares',
|
||||
docsUrl: 'https://vue-bits.dev/backgrounds/squares',
|
||||
tags: []
|
||||
},
|
||||
'Backgrounds/Threads': {
|
||||
videoUrl: '/assets/videos/threads.webm',
|
||||
description: 'Animated pattern of lines forming a fabric-like motion.',
|
||||
category: 'Backgrounds',
|
||||
name: 'Threads',
|
||||
docsUrl: 'https://vue-bits.dev/backgrounds/threads',
|
||||
tags: []
|
||||
},
|
||||
'Backgrounds/Waves': {
|
||||
videoUrl: '/assets/videos/waves.webm',
|
||||
description: 'Layered lines that form smooth wave patterns with animation.',
|
||||
category: 'Backgrounds',
|
||||
name: 'Waves',
|
||||
docsUrl: 'https://vue-bits.dev/backgrounds/waves',
|
||||
tags: []
|
||||
},
|
||||
'Backgrounds/LiquidEther': {
|
||||
videoUrl: '/assets/videos/liquidether.webm',
|
||||
description: 'Interactive liquid shader with flowing distortion and customizable colors.',
|
||||
category: 'Backgrounds',
|
||||
name: 'LiquidEther',
|
||||
docsUrl: 'https://vue-bits.dev/backgrounds/liquid-ether',
|
||||
tags: []
|
||||
}
|
||||
};
|
||||
@@ -2,3 +2,4 @@
|
||||
@import './sidebar.css';
|
||||
@import './category.css';
|
||||
@import './landing.css';
|
||||
@import './transitions.css';
|
||||
|
||||
42
src/css/transitions.css
Normal file
42
src/css/transitions.css
Normal file
@@ -0,0 +1,42 @@
|
||||
.page-transition-fade {
|
||||
transition: opacity 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.page-transition-fade-out {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.page-transition-fade-in {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sidebar-item {
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.sidebar-item:hover {
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
.sidebar-item.transitioning {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.category-page.loading {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.skeleton-loader {
|
||||
animation: fadeIn 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -1,70 +1,52 @@
|
||||
<template>
|
||||
<div ref="scrollRef">
|
||||
<div class="page-transition-fade">
|
||||
<h2 class="sub-category">{{ decodedLabel }}</h2>
|
||||
<IndexPage v-if="isIndexPage" />
|
||||
|
||||
<Suspense>
|
||||
<template #default>
|
||||
<component :is="SubcategoryComponent" v-if="SubcategoryComponent" />
|
||||
</template>
|
||||
<div class="page-transition-fade" v-else>
|
||||
<h2 class="sub-category">{{ decodedLabel }}</h2>
|
||||
|
||||
<template #fallback>
|
||||
<div class="loading-placeholder"></div>
|
||||
</template>
|
||||
</Suspense>
|
||||
<Suspense v-if="SubcategoryComponent">
|
||||
<template #default>
|
||||
<component :is="SubcategoryComponent" v-if="SubcategoryComponent" />
|
||||
</template>
|
||||
|
||||
<template #fallback>
|
||||
<div class="loading-placeholder"></div>
|
||||
</template>
|
||||
</Suspense>
|
||||
<div v-else class="p-6">
|
||||
<h3 class="font-semibold text-white text-lg">Not Found</h3>
|
||||
<p class="text-[#a6a6a6] text-sm">This section is unavailable.</p>
|
||||
</div>
|
||||
|
||||
<BackToTopButton />
|
||||
</div>
|
||||
|
||||
<BackToTopButton />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, watch, onMounted, nextTick, defineAsyncComponent, useTemplateRef } from 'vue';
|
||||
import BackToTopButton from '@/components/common/BackToTopButton.vue';
|
||||
import { computed, defineAsyncComponent, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { componentMap } from '../constants/Components';
|
||||
import { decodeLabel } from '../utils/utils';
|
||||
import BackToTopButton from '@/components/common/BackToTopButton.vue';
|
||||
import IndexPage from './IndexPage.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const scrollRef = useTemplateRef<HTMLDivElement>('scrollRef');
|
||||
|
||||
const subcategory = computed(() => route.params.subcategory as string);
|
||||
const decodedLabel = computed(() => decodeLabel(subcategory.value));
|
||||
const isIndexPage = computed(() => subcategory.value === 'index');
|
||||
|
||||
const SubcategoryComponent = computed(() => {
|
||||
if (!subcategory.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const componentLoader = componentMap[subcategory.value as keyof typeof componentMap];
|
||||
|
||||
if (!componentLoader) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return defineAsyncComponent(componentLoader);
|
||||
const key = subcategory.value as keyof typeof componentMap;
|
||||
const loader = componentMap[key];
|
||||
return loader ? defineAsyncComponent(loader) : null;
|
||||
});
|
||||
|
||||
watch(
|
||||
decodedLabel,
|
||||
label => {
|
||||
if (label) {
|
||||
document.title = `Vue Bits - ${label}`;
|
||||
}
|
||||
if (label) document.title = `Vue Bits - ${label}`;
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(subcategory, async () => {
|
||||
if (scrollRef.value) {
|
||||
await nextTick();
|
||||
scrollRef.value.scrollTo(0, 0);
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (scrollRef.value) {
|
||||
scrollRef.value.scrollTo(0, 0);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
46
src/pages/FavoritesPage.vue
Normal file
46
src/pages/FavoritesPage.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<main class="app-container">
|
||||
<Header />
|
||||
|
||||
<section class="category-wrapper">
|
||||
<Sidebar />
|
||||
|
||||
<div class="category-page">
|
||||
<ComponentList :list="savedList" title="Favorites" sorting="none" has-delete-button />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ComponentList from '@/components/common/ComponentList.vue';
|
||||
import Header from '@/components/navs/Header.vue';
|
||||
import Sidebar from '@/components/navs/Sidebar.vue';
|
||||
import { componentMetadata } from '@/constants/Information';
|
||||
import { getSavedComponents } from '@/utils/favorites';
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
|
||||
const savedKeys = ref<string[]>(getSavedComponents());
|
||||
|
||||
const update = () => (savedKeys.value = getSavedComponents());
|
||||
const onStorage = (e?: StorageEvent | null) => {
|
||||
if (!e || e.key === 'savedComponents') update();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('favorites:updated', update);
|
||||
window.addEventListener('storage', onStorage);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('favorites:updated', update);
|
||||
window.removeEventListener('storage', onStorage);
|
||||
});
|
||||
|
||||
const savedList = computed(() => {
|
||||
const entries = (savedKeys.value || [])
|
||||
.filter(k => typeof k === 'string' && k.includes('/') && componentMetadata?.[k])
|
||||
.map(k => [k, componentMetadata[k]]);
|
||||
return Object.fromEntries(entries);
|
||||
});
|
||||
</script>
|
||||
11
src/pages/IndexPage.vue
Normal file
11
src/pages/IndexPage.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<div>
|
||||
<title>Vue Bits - Component Index</title>
|
||||
<ComponentList :list="componentMetadata" title="Index" sorting="alphabetical" has-favorite-button />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ComponentList from '@/components/common/ComponentList.vue';
|
||||
import { componentMetadata } from '@/constants/Information';
|
||||
</script>
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import LandingPage from '@/pages/LandingPage.vue';
|
||||
import CategoryPage from '@/pages/CategoryPage.vue';
|
||||
import FavoritesPage from '@/pages/FavoritesPage.vue';
|
||||
import CategoryLayout from '@/components/layouts/CategoryLayout.vue';
|
||||
|
||||
const router = createRouter({
|
||||
@@ -21,6 +22,11 @@ const router = createRouter({
|
||||
component: CategoryPage
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/favorites',
|
||||
name: 'favorites',
|
||||
component: FavoritesPage
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
66
src/utils/favorites.ts
Normal file
66
src/utils/favorites.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
const STORAGE_KEY = 'savedComponents';
|
||||
|
||||
const read = (): string[] => {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
const parsed = raw ? JSON.parse(raw) : [];
|
||||
return Array.isArray(parsed) ? parsed.filter(x => typeof x === 'string') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const write = (list: string[]) => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(list));
|
||||
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('favorites:updated', { detail: list }));
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
};
|
||||
|
||||
export const getSavedComponents = () => read();
|
||||
|
||||
export const isComponentSaved = (key: string) => read().includes(key);
|
||||
|
||||
export const addSavedComponent = (key: string) => {
|
||||
const list = read();
|
||||
if (!list.includes(key)) {
|
||||
const next = [...list, key];
|
||||
write(next);
|
||||
return next;
|
||||
}
|
||||
return list;
|
||||
};
|
||||
|
||||
export const removeSavedComponent = (key: string) => {
|
||||
const list = read();
|
||||
const next = list.filter(item => item !== key);
|
||||
write(next);
|
||||
return next;
|
||||
};
|
||||
|
||||
export const toggleSavedComponent = (key: string) => {
|
||||
const list = read();
|
||||
if (list.includes(key)) {
|
||||
const next = list.filter(item => item !== key);
|
||||
write(next);
|
||||
return { saved: false, list: next };
|
||||
}
|
||||
const next = [...list, key];
|
||||
write(next);
|
||||
return { saved: true, list: next };
|
||||
};
|
||||
|
||||
export default {
|
||||
getSavedComponents,
|
||||
isComponentSaved,
|
||||
addSavedComponent,
|
||||
removeSavedComponent,
|
||||
toggleSavedComponent
|
||||
};
|
||||
30
src/utils/fuzzy.ts
Normal file
30
src/utils/fuzzy.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export const levenshtein = (a: string, b: string): number => {
|
||||
const m = a.length,
|
||||
n = b.length;
|
||||
const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
|
||||
for (let i = 0; i <= m; i++) dp[i][0] = i;
|
||||
for (let j = 0; j <= n; j++) dp[0][j] = j;
|
||||
for (let i = 1; i <= m; i++) {
|
||||
for (let j = 1; j <= n; j++) {
|
||||
dp[i][j] =
|
||||
a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] : Math.min(dp[i - 1][j - 1] + 1, dp[i][j - 1] + 1, dp[i - 1][j] + 1);
|
||||
}
|
||||
}
|
||||
return dp[m][n];
|
||||
};
|
||||
|
||||
export const fuzzyMatch = (candidate?: string, query?: string): boolean => {
|
||||
const lowerCandidate = (candidate || '').toLowerCase();
|
||||
const lowerQuery = (query || '').toLowerCase();
|
||||
if (!lowerQuery) return true;
|
||||
if (lowerCandidate.includes(lowerQuery)) return true;
|
||||
const candidateWords = lowerCandidate.split(/\s+/).filter(Boolean);
|
||||
const queryWords = lowerQuery.split(/\s+/).filter(Boolean);
|
||||
return queryWords.every(qw =>
|
||||
candidateWords.some(cw => {
|
||||
const distance = levenshtein(cw, qw);
|
||||
const threshold = Math.max(1, Math.floor(qw.length / 3));
|
||||
return distance <= threshold;
|
||||
})
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user