FEAT: OCTOBER UPDATE

This commit is contained in:
Utkarsh-Singhal-26
2025-10-31 10:33:33 +05:30
parent 88948f5af4
commit 8d07c6b041
239 changed files with 2237 additions and 150 deletions

View 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>

View 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>

View File

@@ -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>

View File

@@ -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;

View File

@@ -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)
}
);