justin.deal/src/components/homelab/CategorySection.astro

157 lines
4.3 KiB
Plaintext
Raw Normal View History

---
import { type Service } from "../../lib/types";
import ServiceCard from "../common/ServiceCard.astro";
2025-05-03 14:06:52 -07:00
import ScrollReveal from "../common/ScrollReveal.astro";
/**
* CategorySection component displays a collapsible section of services grouped by category
* @component
* @example
* ```astro
* <CategorySection
* category="Development"
* apps={[
* {
* name: "Gitea",
* link: "https://code.justin.deal",
* icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/gitea.svg",
* alt: "Gitea"
* }
* ]}
* />
* ```
*/
interface Props {
/**
* The category name to display as the section title
*/
category: string;
/**
* Array of service objects to display in this category
*/
apps: Service[];
}
const { category, apps } = Astro.props;
// Pre-compute values during server-side rendering
const categoryId = `category-${category.toLowerCase().replace(/\s+/g, '-')}`;
const categoryLower = category.toLowerCase();
---
<div class="mb-8 category-section" data-category={categoryLower} x-data="{ open: true, hover: false }">
<button
@click="open = !open"
@mouseenter="hover = true"
@mouseleave="hover = false"
class="category-toggle text-xl font-semibold mb-4 w-full text-left flex items-center justify-between relative overflow-hidden"
aria-expanded="true"
:aria-expanded="open.toString()"
aria-controls={categoryId}
>
<span class="relative z-10 category-title">{category}</span>
<!-- Background animation element -->
<span
class="category-bg absolute inset-0 opacity-0 transition-opacity duration-300"
:class="{ 'opacity-5': hover }"
></span>
<svg
:class="{ 'rotate-180': open, 'translate-y-1': hover && !open, '-translate-y-1': hover && open }"
class="w-5 h-5 transform transition-all duration-300 relative z-10"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<div
x-show="open"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 transform -translate-y-4"
x-transition:enter-end="opacity-100 transform translate-y-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 transform translate-y-0"
x-transition:leave-end="opacity-0 transform -translate-y-4"
id={categoryId}
>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-6 transition-all duration-300">
{apps.length > 0 ? (
apps.map(app => {
const appName = app.name.toLowerCase();
const appTags = app.tags ? app.tags.join(' ').toLowerCase() : '';
return (
2025-05-03 14:06:52 -07:00
<ScrollReveal
animation="fade-up"
delay={100 * apps.indexOf(app)}
duration={500}
threshold={0.1}
>
2025-05-03 14:06:52 -07:00
<div
class="app-card transition-all duration-300"
data-app-name={appName}
data-app-tags={appTags}
data-app-category={categoryLower}
>
<ServiceCard
name={app.name}
href={app.link}
img={app.icon}
imgDark={app.iconDark}
2025-05-03 14:06:52 -07:00
alt={app.name}
/>
</div>
</ScrollReveal>
);
})
) : (
<p class="text-center col-span-full">Coming soon...</p>
)}
</div>
</div>
</div>
<style>
/* Category toggle button styles */
.category-toggle {
padding: 0.5rem;
border-radius: 0.375rem;
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
position: relative;
}
.category-toggle:hover {
padding-left: 1rem;
}
.category-toggle:active {
transform: scale(0.98);
}
.category-bg {
background: var(--color-zag-accent);
border-radius: 0.375rem;
}
/* Title animation */
.category-title {
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
display: inline-block;
}
.category-toggle:hover .category-title {
transform: translateX(0.25rem);
}
/* Focus styles for accessibility */
.category-toggle:focus-visible {
outline: 2px solid var(--color-zag-accent-dark);
outline-offset: 2px;
}
</style>