mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-07 14:39:30 -07:00
FEAT: OCTOBER UPDATE
This commit is contained in:
540
src/components/common/ComponentList.vue
Normal file
540
src/components/common/ComponentList.vue
Normal file
@@ -0,0 +1,540 @@
|
||||
<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="{
|
||||
height: `${CARD_HEIGHT}px`,
|
||||
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)
|
||||
}
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user