2025-05-03 00:44:33 -07:00
|
|
|
---
|
2025-05-03 13:19:10 -07:00
|
|
|
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";
|
2025-05-03 13:19:10 -07:00
|
|
|
|
|
|
|
/**
|
|
|
|
* 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"
|
|
|
|
* }
|
|
|
|
* ]}
|
|
|
|
* />
|
|
|
|
* ```
|
|
|
|
*/
|
2025-05-03 00:44:33 -07:00
|
|
|
interface Props {
|
2025-05-03 13:19:10 -07:00
|
|
|
/**
|
|
|
|
* The category name to display as the section title
|
|
|
|
*/
|
2025-05-03 00:44:33 -07:00
|
|
|
category: string;
|
2025-05-03 13:19:10 -07:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Array of service objects to display in this category
|
|
|
|
*/
|
|
|
|
apps: Service[];
|
2025-05-03 00:44:33 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
const { category, apps } = Astro.props;
|
|
|
|
|
|
|
|
// Pre-compute values during server-side rendering
|
|
|
|
const categoryId = `category-${category.toLowerCase().replace(/\s+/g, '-')}`;
|
|
|
|
const categoryLower = category.toLowerCase();
|
|
|
|
---
|
|
|
|
|
2025-05-03 15:31:48 -07:00
|
|
|
<div class="mb-8 category-section" data-category={categoryLower} x-data="{ open: true, hover: false }">
|
2025-05-03 00:44:33 -07:00
|
|
|
<button
|
|
|
|
@click="open = !open"
|
2025-05-03 15:31:48 -07:00
|
|
|
@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"
|
2025-05-03 00:44:33 -07:00
|
|
|
aria-expanded="true"
|
|
|
|
:aria-expanded="open.toString()"
|
|
|
|
aria-controls={categoryId}
|
|
|
|
>
|
2025-05-03 15:31:48 -07:00
|
|
|
<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>
|
|
|
|
|
2025-05-03 00:44:33 -07:00
|
|
|
<svg
|
2025-05-03 15:31:48 -07:00
|
|
|
: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"
|
2025-05-03 00:44:33 -07:00
|
|
|
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"
|
2025-05-03 15:31:48 -07:00
|
|
|
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"
|
2025-05-03 00:44:33 -07:00
|
|
|
id={categoryId}
|
|
|
|
>
|
2025-05-04 11:36:24 -07:00
|
|
|
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-6 transition-all duration-300">
|
2025-05-03 00:44:33 -07:00
|
|
|
{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 00:44:33 -07:00
|
|
|
>
|
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}
|
2025-05-06 18:41:42 -07:00
|
|
|
imgDark={app.iconDark}
|
2025-05-03 14:06:52 -07:00
|
|
|
alt={app.name}
|
|
|
|
/>
|
|
|
|
</div>
|
|
|
|
</ScrollReveal>
|
2025-05-03 00:44:33 -07:00
|
|
|
);
|
|
|
|
})
|
|
|
|
) : (
|
|
|
|
<p class="text-center col-span-full">Coming soon...</p>
|
|
|
|
)}
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
2025-05-03 15:31:48 -07:00
|
|
|
|
|
|
|
<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>
|