2025-04-27 20:43:15 -07:00
|
|
|
---
|
|
|
|
import { GLOBAL } from "../../lib/variables";
|
|
|
|
import Layout from "../../layouts/Layout.astro";
|
|
|
|
import Section from "../../components/common/Section.astro";
|
2025-05-03 00:44:33 -07:00
|
|
|
import SearchBar from "../../components/common/SearchBar.astro";
|
2025-05-03 13:19:10 -07:00
|
|
|
import StyleControls from "../../components/common/StyleControls.astro";
|
2025-05-03 13:24:56 -07:00
|
|
|
import StyleControlsScript from "../../components/StyleControlsScript.astro";
|
2025-05-03 00:44:33 -07:00
|
|
|
import CategorySection from "../../components/homelab/CategorySection.astro";
|
2025-05-03 02:27:26 -07:00
|
|
|
import ServiceCardSkeleton from "../../components/common/ServiceCardSkeleton.astro";
|
|
|
|
import SkeletonLoader from "../../components/common/SkeletonLoader.astro";
|
2025-05-03 13:19:10 -07:00
|
|
|
import SEO from "../../components/SEO.astro";
|
|
|
|
import StructuredData from "../../components/StructuredData.astro";
|
2025-04-27 20:43:15 -07:00
|
|
|
import { services } from "./services.ts";
|
2025-05-03 13:19:10 -07:00
|
|
|
|
|
|
|
// Count total services
|
|
|
|
const totalServices = Object.values(services).reduce(
|
|
|
|
(count, serviceList) => count + serviceList.length,
|
|
|
|
0
|
|
|
|
);
|
|
|
|
|
|
|
|
// Structured data for the homelab page
|
|
|
|
const webpageData = {
|
|
|
|
name: "Homelab Dashboard",
|
|
|
|
description: `A collection of ${totalServices} self-hosted services and applications running on my personal homelab.`,
|
|
|
|
url: `${GLOBAL.rootUrl}/homelab`,
|
|
|
|
isPartOf: {
|
|
|
|
"@type": "WebSite",
|
|
|
|
"name": `${GLOBAL.username} • ${GLOBAL.shortDescription}`,
|
|
|
|
"url": GLOBAL.rootUrl
|
|
|
|
}
|
|
|
|
};
|
2025-04-27 20:43:15 -07:00
|
|
|
---
|
|
|
|
|
|
|
|
<Layout>
|
2025-05-03 13:19:10 -07:00
|
|
|
<SEO
|
|
|
|
slot="head"
|
|
|
|
title="Homelab Dashboard"
|
|
|
|
description={`A collection of ${totalServices} self-hosted services and applications running on my personal homelab.`}
|
|
|
|
canonicalUrl={`${GLOBAL.rootUrl}/homelab`}
|
|
|
|
/>
|
|
|
|
<StructuredData slot="head" type="WebPage" data={webpageData} />
|
2025-04-27 20:43:15 -07:00
|
|
|
|
2025-05-03 13:19:10 -07:00
|
|
|
<!-- Search functionality is provided by SearchScript.astro -->
|
2025-05-03 13:24:56 -07:00
|
|
|
<!-- Keyboard shortcuts for style controls -->
|
|
|
|
<StyleControlsScript />
|
2025-05-03 01:35:48 -07:00
|
|
|
|
2025-05-03 13:35:55 -07:00
|
|
|
<Section class="my-2">
|
2025-05-03 02:27:26 -07:00
|
|
|
<div x-data="searchServices" x-init="init()" x-cloak>
|
2025-05-03 13:19:10 -07:00
|
|
|
<!-- Search and controls container -->
|
2025-05-03 00:44:33 -07:00
|
|
|
<div class="mb-4 pt-0">
|
2025-05-03 13:35:55 -07:00
|
|
|
<!-- Style controls in a centered row above search -->
|
|
|
|
<div class="w-full flex justify-center mb-4">
|
|
|
|
<StyleControls />
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<!-- Search bar below style controls -->
|
|
|
|
<div class="w-full">
|
2025-05-03 00:44:33 -07:00
|
|
|
<SearchBar
|
|
|
|
placeholder="Search services..."
|
|
|
|
ariaLabel="Search services"
|
2025-05-02 23:16:34 -07:00
|
|
|
/>
|
|
|
|
</div>
|
|
|
|
</div>
|
2025-05-02 23:46:26 -07:00
|
|
|
|
2025-05-03 00:44:33 -07:00
|
|
|
<!-- Page heading - now below search -->
|
|
|
|
<div class="flex items-center gap-4 pb-4 mt-2">
|
|
|
|
<h1 class="font-display text-3xl sm:text-4xl leading-loose">Homelab</h1>
|
|
|
|
</div>
|
|
|
|
|
2025-05-02 23:46:26 -07:00
|
|
|
<!-- No results message -->
|
|
|
|
<div
|
|
|
|
x-show="searchQuery !== '' && !hasResults"
|
|
|
|
x-transition
|
2025-05-03 00:44:33 -07:00
|
|
|
class="text-center py-8 my-4 border-2 border-dashed border-current zag-text rounded-lg"
|
2025-05-02 23:46:26 -07:00
|
|
|
>
|
2025-05-03 00:44:33 -07:00
|
|
|
<p class="text-xl font-semibold zag-text">No Results</p>
|
2025-05-02 23:46:26 -07:00
|
|
|
</div>
|
2025-04-27 20:43:15 -07:00
|
|
|
|
2025-05-03 02:27:26 -07:00
|
|
|
<!-- Loading skeleton -->
|
|
|
|
<div
|
|
|
|
x-show="loading"
|
|
|
|
x-transition:enter="transition ease-out duration-300"
|
|
|
|
x-transition:enter-start="opacity-0"
|
|
|
|
x-transition:enter-end="opacity-100"
|
|
|
|
x-transition:leave="transition ease-in duration-200"
|
|
|
|
x-transition:leave-start="opacity-100"
|
|
|
|
x-transition:leave-end="opacity-0"
|
|
|
|
>
|
|
|
|
{Object.entries(services).map(([category, apps]) => (
|
|
|
|
<div class="mb-8">
|
|
|
|
<div class="text-xl font-semibold mb-4 w-full text-left flex items-center justify-between">
|
|
|
|
<SkeletonLoader type="title" width="40%" height="1.5rem" />
|
|
|
|
</div>
|
|
|
|
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
|
|
|
|
{Array.from({ length: apps.length || 4 }).map((_, i) => (
|
|
|
|
<ServiceCardSkeleton />
|
|
|
|
))}
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
))}
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<!-- Service categories (actual content) -->
|
|
|
|
<div
|
|
|
|
id="app-list"
|
|
|
|
x-show="!loading"
|
|
|
|
x-transition:enter="transition ease-out duration-300"
|
|
|
|
x-transition:enter-start="opacity-0"
|
|
|
|
x-transition:enter-end="opacity-100"
|
|
|
|
>
|
2025-05-03 00:44:33 -07:00
|
|
|
{Object.entries(services).map(([category, apps]) => (
|
|
|
|
<CategorySection category={category} apps={apps} />
|
2025-05-02 23:46:26 -07:00
|
|
|
))}
|
|
|
|
</div>
|
2025-05-02 23:16:34 -07:00
|
|
|
</div>
|
2025-04-27 20:43:15 -07:00
|
|
|
</Section>
|
|
|
|
</Layout>
|