Compare commits

...

3 Commits

Author SHA1 Message Date
6497f5dfb8 update tag styling to be consistent
All checks were successful
Build and Deploy / build (push) Successful in 30s
2025-05-03 02:35:37 -07:00
09b838819b fix tag alignment 2025-05-03 02:30:33 -07:00
b8a463f009 Additional UI Enhancements and Update Project Page for this Site 2025-05-03 02:27:26 -07:00
18 changed files with 726 additions and 50 deletions

View File

@ -27,9 +27,9 @@ const { title, description, url, duration, timestamp, tags } = Astro.props;
<p class="">
{description}
</p>
<div class="flex flex-wrap gap-2">
<div class="flex flex-wrap gap-2 w-full">
{tags?.map((tag: string) => (
<span class="-zag-text -zag-bg zag-transition px-2 py-1 text-sm font-semibold">
<span class="-zag-text -zag-bg zag-transition px-2 py-1 text-sm font-semibold rounded-md whitespace-nowrap overflow-hidden text-ellipsis max-w-[150px]" title={tag}>
{tag}
</span>
))}

View File

@ -0,0 +1,33 @@
---
import SkeletonLoader from "./common/SkeletonLoader.astro";
interface Props {
className?: string;
}
const { className = "" } = Astro.props;
---
<div class={`article-snippet-skeleton ${className} mb-8`}>
<!-- Title skeleton -->
<div class="mb-2">
<SkeletonLoader type="title" height="1.75rem" width="85%" />
</div>
<!-- Date skeleton -->
<div class="mb-3">
<SkeletonLoader type="text" height="1rem" width="30%" />
</div>
<!-- Description skeleton -->
<div class="mb-4">
<SkeletonLoader type="paragraph" height="3rem" />
</div>
<!-- Tags skeleton -->
<div class="flex flex-wrap gap-2">
<SkeletonLoader type="text" height="1.5rem" width="60px" rounded={true} />
<SkeletonLoader type="text" height="1.5rem" width="80px" rounded={true} />
<SkeletonLoader type="text" height="1.5rem" width="70px" rounded={true} />
</div>
</div>

View File

@ -38,10 +38,10 @@ const { title, description, url, githubUrl, liveUrl, tags } = Astro.props;
<p class="zag-text zag-transition">
{description}
</p>
<div class="flex flex-row wrap gap-2">
<div class="flex flex-wrap gap-2 w-full">
{
tags.map((tag) => (
<span class="-zag-text -zag-bg zag-transition px-2 py-1 text-sm font-semibold">
<span class="-zag-text -zag-bg zag-transition px-2 py-1 text-sm font-semibold rounded-md whitespace-nowrap overflow-hidden text-ellipsis max-w-[150px]" title={tag}>
{tag}
</span>
))

View File

@ -0,0 +1,43 @@
---
import SkeletonLoader from "./common/SkeletonLoader.astro";
interface Props {
className?: string;
}
const { className = "" } = Astro.props;
---
<div class={`project-snippet-skeleton ${className} mb-8`}>
<div class="flex flex-col md:flex-row gap-6">
<!-- Image skeleton -->
<div class="md:w-1/3">
<SkeletonLoader type="image" height="200px" rounded={true} />
</div>
<div class="md:w-2/3">
<!-- Title skeleton -->
<div class="mb-2">
<SkeletonLoader type="title" height="1.75rem" width="85%" />
</div>
<!-- Description skeleton -->
<div class="mb-4">
<SkeletonLoader type="paragraph" height="4rem" />
</div>
<!-- Links skeleton -->
<div class="flex gap-4 mb-3">
<SkeletonLoader type="text" height="1.25rem" width="100px" />
<SkeletonLoader type="text" height="1.25rem" width="100px" />
</div>
<!-- Tags skeleton -->
<div class="flex flex-wrap gap-2">
<SkeletonLoader type="text" height="1.5rem" width="60px" rounded={true} />
<SkeletonLoader type="text" height="1.5rem" width="80px" rounded={true} />
<SkeletonLoader type="text" height="1.5rem" width="70px" rounded={true} />
</div>
</div>
</div>
</div>

View File

@ -24,16 +24,23 @@ function initializeSearch(contentSelector = '.searchable-item', options = {}) {
searchQuery: '',
hasResults: true,
visibleCount: 0,
loading: false, // Start with loading state false - the LoadingManager will control this
init() {
// Initialize the visible count
this.visibleCount = document.querySelectorAll(contentSelector).length;
this.setupWatchers();
this.setupKeyboardShortcuts();
// Handle theme changes
window.addEventListener('theme-changed', () => {
this.filterContent(this.searchQuery);
});
},
setupWatchers() {
this.$watch('searchQuery', (query) => {
// Filter content immediately - no artificial delay
this.filterContent(query);
});
},

View File

@ -0,0 +1,224 @@
---
interface Props {
message?: string;
subMessage?: string;
className?: string;
}
const {
message = "This is taking longer than expected...",
subMessage = "Still working on loading your content",
className = ""
} = Astro.props;
---
<div
class={`loading-overlay fixed inset-0 flex flex-col items-center justify-center z-50 bg-opacity-90 zag-bg ${className}`}
role="alert"
aria-live="polite"
>
<div class="loading-content text-center p-8 rounded-lg">
<!-- Spinner -->
<div class="spinner-container mb-6 flex justify-center">
<div class="spinner"></div>
</div>
<!-- Messages -->
<p class="text-xl font-semibold mb-2 zag-text">{message}</p>
<p class="text-base zag-text-muted">{subMessage}</p>
</div>
</div>
<style>
.loading-overlay {
opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease, visibility 0.3s ease;
}
.loading-overlay.visible {
opacity: 1;
visibility: visible;
}
.spinner {
width: 48px;
height: 48px;
border: 4px solid var(--color-zag-dark-muted);
border-radius: 50%;
border-top-color: var(--color-zag-accent-dark);
animation: spin 1.5s cubic-bezier(0.68, -0.55, 0.27, 1.55) infinite;
}
:global(.dark) .spinner {
border-color: var(--color-zag-light-muted);
border-top-color: var(--color-zag-accent-dark);
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.spinner {
animation-duration: 3s;
}
}
</style>
<script>
// This script will be executed client-side
class LoadingManager {
overlay: HTMLElement | null = null;
timeoutId: ReturnType<typeof setTimeout> | null = null;
longLoadingTimeoutId: ReturnType<typeof setTimeout> | null = null;
constructor() {
this.overlay = document.querySelector('.loading-overlay');
this.setupNavigationListeners();
}
setupNavigationListeners() {
// Listen for navigation events if the Navigation API is supported
if ('navigation' in window) {
// @ts-ignore - Navigation API might not be recognized by TypeScript
window.navigation.addEventListener('navigate', (event) => {
// Only handle same-origin navigations
if (new URL(event.destination.url).origin === window.location.origin) {
this.showLoading();
}
});
// @ts-ignore
window.navigation.addEventListener('navigatesuccess', () => {
this.hideLoading();
});
// @ts-ignore
window.navigation.addEventListener('navigateerror', () => {
this.hideLoading();
});
} else {
// Fallback for browsers without Navigation API
window.addEventListener('beforeunload', () => {
this.showLoading();
});
// For SPA navigation (if using client-side routing)
document.addEventListener('astro:page-load', () => {
this.hideLoading();
});
}
// For Astro's view transitions
document.addEventListener('astro:before-swap', () => {
this.showLoading();
});
document.addEventListener('astro:after-swap', () => {
this.hideLoading();
});
}
showLoading() {
// Clear any existing timeouts
if (this.timeoutId) {
clearTimeout(this.timeoutId);
this.timeoutId = null;
}
if (this.longLoadingTimeoutId) {
clearTimeout(this.longLoadingTimeoutId);
this.longLoadingTimeoutId = null;
}
// Check connection speed if available
const showDelay = this.getConnectionBasedDelay();
// Only show loading UI after a short delay to avoid flashing on fast loads
this.timeoutId = setTimeout(() => {
// Set loading state in Alpine.js components if they exist
document.querySelectorAll('[x-data]').forEach(el => {
// @ts-ignore
if (el.__x && typeof el.__x.$data.loading !== 'undefined') {
// @ts-ignore
el.__x.$data.loading = true;
}
});
}, showDelay);
// Show the overlay for long loading times (5+ seconds)
this.longLoadingTimeoutId = setTimeout(() => {
if (this.overlay) {
this.overlay.classList.add('visible');
}
}, 5000);
}
hideLoading() {
// Clear timeouts
if (this.timeoutId) {
clearTimeout(this.timeoutId);
this.timeoutId = null;
}
if (this.longLoadingTimeoutId) {
clearTimeout(this.longLoadingTimeoutId);
this.longLoadingTimeoutId = null;
}
// Hide the overlay
if (this.overlay) {
this.overlay.classList.remove('visible');
}
// Reset loading state in Alpine.js components after a short delay
// This ensures transitions look smooth
setTimeout(() => {
document.querySelectorAll('[x-data]').forEach(el => {
// @ts-ignore
if (el.__x && typeof el.__x.$data.loading !== 'undefined') {
// @ts-ignore
el.__x.$data.loading = false;
}
});
}, 100);
}
getConnectionBasedDelay() {
// Use Network Information API if available
// @ts-ignore - Navigator connection API might not be recognized
const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
if (connection) {
const type = connection.effectiveType;
// Adjust delay based on connection type
switch (type) {
case '4g':
return 100; // Fast connection - short delay
case '3g':
return 200; // Medium connection
case '2g':
case 'slow-2g':
return 0; // Slow connection - show immediately
default:
return 100; // Default delay
}
}
// Default delay if Network Information API is not available
return 100;
}
}
// Initialize the loading manager when the DOM is ready
document.addEventListener('DOMContentLoaded', () => {
new LoadingManager();
});
</script>

View File

@ -0,0 +1,30 @@
---
import SkeletonLoader from "./SkeletonLoader.astro";
interface Props {
className?: string;
}
const { className = "" } = Astro.props;
---
<div class={`service-card-skeleton ${className}`}>
<div class="flex flex-col items-center p-4 border border-current rounded-lg zag-transition">
<!-- Icon skeleton -->
<div class="mb-3">
<SkeletonLoader type="image" width="48px" height="48px" rounded={true} />
</div>
<!-- Title skeleton -->
<div class="w-full text-center mb-2">
<SkeletonLoader type="title" width="80%" height="1.25rem" className="mx-auto" />
</div>
</div>
</div>
<style>
.service-card-skeleton {
height: 100%;
width: 100%;
}
</style>

View File

@ -0,0 +1,134 @@
---
interface Props {
type?: 'card' | 'text' | 'image' | 'title' | 'paragraph' | 'custom';
width?: string;
height?: string;
className?: string;
rounded?: boolean;
count?: number;
animate?: boolean;
}
const {
type = 'card',
width,
height,
className = '',
rounded = false,
count = 1,
animate = true,
} = Astro.props;
// Determine dimensions based on type
let defaultWidth = '100%';
let defaultHeight = '100%';
switch (type) {
case 'card':
defaultWidth = '100%';
defaultHeight = '200px';
break;
case 'text':
defaultWidth = '100%';
defaultHeight = '1rem';
break;
case 'image':
defaultWidth = '100%';
defaultHeight = '150px';
break;
case 'title':
defaultWidth = '70%';
defaultHeight = '1.5rem';
break;
case 'paragraph':
defaultWidth = '100%';
defaultHeight = '4rem';
break;
}
const finalWidth = width || defaultWidth;
const finalHeight = height || defaultHeight;
const roundedClass = rounded ? 'rounded-lg' : '';
const animateClass = animate ? 'animate-pulse' : '';
// Generate multiple skeleton items if count > 1
const items = Array.from({ length: count }, (_, i) => i);
---
{
items.map((_, index) => (
<div
class:list={[
'skeleton-loader',
'zag-bg-skeleton',
animateClass,
roundedClass,
className
]}
style={`width: ${finalWidth}; height: ${finalHeight};`}
aria-hidden="true"
data-testid="skeleton-loader"
data-index={index}
>
</div>
))
}
<style>
.skeleton-loader {
position: relative;
overflow: hidden;
background-color: var(--color-zag-light-muted);
}
:global(.dark) .skeleton-loader {
background-color: var(--color-zag-dark-muted);
}
.skeleton-loader::after {
content: "";
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
transform: translateX(-100%);
background-image: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0,
rgba(255, 255, 255, 0.1) 20%,
rgba(255, 255, 255, 0.2) 60%,
rgba(255, 255, 255, 0)
);
animation: shimmer 2s infinite;
}
:global(.dark) .skeleton-loader::after {
background-image: linear-gradient(
90deg,
rgba(0, 0, 0, 0) 0,
rgba(0, 0, 0, 0.1) 20%,
rgba(0, 0, 0, 0.2) 60%,
rgba(0, 0, 0, 0)
);
}
@keyframes shimmer {
100% {
transform: translateX(100%);
}
}
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
</style>

View File

@ -50,13 +50,13 @@ const sourceUrl = generateSourceUrl(frontmatter.filename, "blog");
<Prose>
<slot />
</Prose>
<div class="flex flex-wrap gap-2 mt-4">
<div class="flex flex-wrap gap-2 w-full mt-8 mb-4">
{frontmatter.tags?.map((tag) => (
<span class="-zag-text -zag-bg zag-transition px-2 py-1 text-sm font-semibold">
<span class="-zag-text -zag-bg zag-transition px-2 py-1 text-sm font-semibold rounded-md whitespace-nowrap overflow-hidden text-ellipsis max-w-[150px]" title={tag}>
{tag}
</span>
))}
</div>
<p class="pt-8">~{GLOBAL.username}</p>
<p class="pt-4">~{GLOBAL.username}</p>
</Section>
</Layout>

View File

@ -2,6 +2,7 @@
import Footer from "../components/Footer.astro";
import Header from "../components/Header.astro";
import SearchScript from "../components/SearchScript.astro";
import LoadingOverlay from "../components/common/LoadingOverlay.astro";
import "../styles/global.css";
---
@ -51,6 +52,9 @@ import "../styles/global.css";
<slot name="head" />
</head>
<body class="zag-bg zag-text zag-transition font-mono">
<!-- Loading overlay for long loading times -->
<LoadingOverlay />
<Header />
<main>
<slot />

View File

@ -37,11 +37,11 @@ const sourceUrl = generateSourceUrl(frontmatter.filename, "projects");
<h1 class="text-3xl sm:text-4xl leading-tight font-display">
{frontmatter.title}
</h1>
<div class="flex text-sm gap-2">
<div class="flex flex-wrap gap-2 w-full">
{
frontmatter.tags
? frontmatter.tags.map((tag) => (
<span class="-zag-text -zag-bg zag-transition font-semibold py-1 px-2">
<span class="-zag-text -zag-bg zag-transition px-2 py-1 text-sm font-semibold rounded-md whitespace-nowrap overflow-hidden text-ellipsis max-w-[150px]" title={tag}>
{tag}
</span>
))

View File

@ -28,6 +28,7 @@ export const processContentInDir = async <T extends object, K>(
.filter((file: string) => file.endsWith(".md"))
.map((file) => file.split(".")[0]);
const readMdFileContent = async (file: string) => {
try {
if (contentType === "projects") {
const content = import.meta
.glob(`/src/pages/projects/*.md`)
@ -37,6 +38,13 @@ export const processContentInDir = async <T extends object, K>(
file: string;
url: string;
};
// Validate frontmatter before processing
if (!data || !data.frontmatter) {
console.warn(`Warning: Missing or invalid frontmatter in ${file}.md`);
return null;
}
return processFn(data);
} else {
const content = import.meta
@ -47,10 +55,23 @@ export const processContentInDir = async <T extends object, K>(
file: string;
url: string;
};
// Validate frontmatter before processing
if (!data || !data.frontmatter) {
console.warn(`Warning: Missing or invalid frontmatter in ${file}.md`);
return null;
}
return processFn(data);
}
} catch (error) {
console.error(`Error processing ${file}.md:`, error);
return null;
}
};
return await Promise.all(markdownFiles.map(readMdFileContent));
const results = await Promise.all(markdownFiles.map(readMdFileContent));
// Filter out null results from files with errors
return results.filter(result => result !== null) as K[];
};
/**

View File

@ -2,9 +2,58 @@
layout: ../../layouts/BlogLayout.astro
title: No, We Have Netflix at Home
description: How my exasperation at paying for an ever growing number of streaming services led to a deep obsession
tags: ["code", "htmlf"]
tags: ["code", "html", "homelab"]
time: 4
featured: true
timestamp: 2024-12-18T02:39:03+00:00
filename: html-intro
---
## The Beginning of an Obsession
It all started with a simple thought: "Why am I paying for so many streaming services?" Netflix, Hulu, Disney+, HBO Max, Apple TV+, and the list goes on. Each one offering just enough exclusive content to justify its monthly fee, but collectively draining my wallet.
That's when I decided to take matters into my own hands and build my own media server. Little did I know this would be the gateway to a much deeper homelab obsession.
## The First Steps
I started with a simple Plex server running on an old laptop. It wasn't much, but it was mine. I could store my legally obtained media and stream it to any device in my home. The convenience was immediately apparent, and the satisfaction of building something myself was addictive.
But as with any tech hobby, it didn't stop there. Soon I was researching NAS solutions, RAID configurations, and the best hard drives for 24/7 operation. My simple media server was evolving into something much more complex.
## Expanding Horizons
As my collection grew, so did my ambitions. I found myself exploring other self-hosted applications:
- **Sonarr and Radarr** for managing TV shows and movies
- **Jackett** for indexing
- **Ombi** for allowing family members to request content
- **Tautulli** for monitoring Plex usage
Each new addition made my system more powerful and more tailored to my specific needs. I was no longer just replicating Netflix; I was building something better.
## The Current Setup
Today, my homelab has expanded well beyond just media. It now includes:
- A proper NAS with redundant storage
- Docker containers for various services
- Home automation integration
- VPN for remote access
- Automated backups
The journey from "I don't want to pay for Netflix" to "I need more hard drives for my server rack" happened almost without me noticing. But I wouldn't have it any other way.
## Lessons Learned
If you're considering starting your own homelab journey, here's what I've learned:
1. **Start small** - You don't need enterprise hardware to begin
2. **Document everything** - You'll thank yourself later
3. **Backup, backup, backup** - Data loss is painful
4. **Join the community** - r/homelab and other forums are invaluable resources
5. **Enjoy the process** - The learning is half the fun
So while my family jokes about having "Netflix at home," I smile knowing that what we have is so much more than just another streaming service. It's a hobby, a learning experience, and a constantly evolving project that brings me joy.
And yes, it's probably cost me more than just paying for those streaming services would have. But where's the fun in that?

View File

@ -2,6 +2,7 @@
import { GLOBAL } from "../../lib/variables";
import Layout from "../../layouts/Layout.astro";
import ArticleSnippet from "../../components/ArticleSnippet.astro";
import ArticleSnippetSkeleton from "../../components/ArticleSnippetSkeleton.astro";
import Section from "../../components/common/Section.astro";
import SearchBar from "../../components/common/SearchBar.astro";
import { articles } from "../../lib/list";
@ -41,7 +42,7 @@ const tagCounts = countTags(articles.map((article) => article.tags).flat().filte
<!-- Search functionality is provided by search-client.js -->
<Section class="my-8">
<div x-data="searchArticles" x-init="init()">
<div x-data="searchArticles" x-init="init()" x-cloak>
<!-- Search container - positioned at the top -->
<div class="mb-4 pt-0">
<div class="w-full">
@ -64,7 +65,29 @@ const tagCounts = countTags(articles.map((article) => article.tags).flat().filte
>
<p class="text-xl font-semibold zag-text">No Articles Found</p>
</div>
<ul id="article-list">
<!-- 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"
>
{Array.from({ length: 5 }).map((_, i) => (
<ArticleSnippetSkeleton />
))}
</div>
<!-- Actual content -->
<ul
id="article-list"
x-show="!loading"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
>
{
articles.map((article) => {
const articleTags = article.tags ? article.tags.join(' ').toLowerCase() : '';

View File

@ -4,6 +4,8 @@ import Layout from "../../layouts/Layout.astro";
import Section from "../../components/common/Section.astro";
import SearchBar from "../../components/common/SearchBar.astro";
import CategorySection from "../../components/homelab/CategorySection.astro";
import ServiceCardSkeleton from "../../components/common/ServiceCardSkeleton.astro";
import SkeletonLoader from "../../components/common/SkeletonLoader.astro";
import { services } from "./services.ts";
import { initializeSearch } from "../../components/common/searchUtils.js";
---
@ -28,7 +30,7 @@ import { initializeSearch } from "../../components/common/searchUtils.js";
<!-- Search functionality is provided by search-client.js -->
<Section class="my-8">
<div x-data="searchServices" x-init="init()">
<div x-data="searchServices" x-init="init()" x-cloak>
<!-- Search container - positioned at the top -->
<div class="mb-4 pt-0">
<div class="w-full">
@ -53,8 +55,38 @@ import { initializeSearch } from "../../components/common/searchUtils.js";
<p class="text-xl font-semibold zag-text">No Results</p>
</div>
<!-- Service categories -->
<div id="app-list">
<!-- 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"
>
{Object.entries(services).map(([category, apps]) => (
<CategorySection category={category} apps={apps} />
))}

View File

@ -2,6 +2,7 @@
import { projects } from "../../lib/list";
import Section from "../../components/common/Section.astro";
import ProjectSnippet from "../../components/ProjectSnippet.astro";
import ProjectSnippetSkeleton from "../../components/ProjectSnippetSkeleton.astro";
import SearchBar from "../../components/common/SearchBar.astro";
import Layout from "../../layouts/Layout.astro";
import { GLOBAL } from "../../lib/variables";
@ -37,7 +38,7 @@ import { initializeSearch } from "../../components/common/searchUtils.js";
<!-- Search functionality is provided by search-client.js -->
<Section class="py-4 my-8">
<div x-data="searchProjects" x-init="init()">
<div x-data="searchProjects" x-init="init()" x-cloak>
<!-- Search container - positioned at the top -->
<div class="mb-4 pt-0">
<div class="w-full">
@ -61,7 +62,29 @@ import { initializeSearch } from "../../components/common/searchUtils.js";
<p class="text-xl font-semibold zag-text">No Projects Found</p>
</div>
<ul id="project-list">
<!-- 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"
>
{Array.from({ length: 3 }).map((_, i) => (
<ProjectSnippetSkeleton />
))}
</div>
<!-- Actual content -->
<ul
id="project-list"
x-show="!loading"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
>
{
projects.map((project) => {
const projectTags = project.tags ? project.tags.join(' ').toLowerCase() : '';

View File

@ -1,8 +1,8 @@
---
layout: ../../layouts/ProjectLayout.astro
title: This Website
description: My personal blog and portfolio, built using TypeScript, TailwindCSS, and Astro.
tags: ["astro", "typescript", "web-development"]
description: My personal blog, portfolio, and homelab dashboard built with Astro, TypeScript, TailwindCSS, and Alpine.js featuring smart loading, advanced search, and accessibility features.
tags: ["astro", "typescript", "tailwindcss", "alpinejs", "responsive-design", "accessibility", "dark-mode", "gruvbox-theme"]
githubUrl: https://code.justin.deal
timestamp: 2025-02-24T02:39:03+00:00
featured: true
@ -11,17 +11,59 @@ filename: zaggonaut
## The Details
Zaggonaut is a retro-inspired theme for Astro, built using TypeScript, TailwindCSS, and Astro. Use this theme to power your own personal website, blog, or portfolio with flexibility and customization.
This website serves as my personal digital space, combining a blog, project portfolio, and homelab dashboard in one cohesive experience. Built on Astro for performance and flexibility, it leverages TypeScript for type safety, TailwindCSS for styling, and Alpine.js for interactive components.
The architecture follows a component-based approach with a focus on reusability and maintainability. The site features a custom Gruvbox-inspired theme system that supports both dark and light modes while maintaining accessibility standards. The responsive design ensures a seamless experience across all device sizes.
## The Features
- Dark & light mode
- Customizable colors
- 100 / 100 Lighthouse score
- Fully accessible
- Fully responsive
- Type-safe
### Content & Navigation
- **Multi-purpose platform**: Blog, portfolio, and homelab dashboard integration
- **Dark & light mode** with smooth transitions and flash prevention
- **Gruvbox-inspired color scheme** with customizable theme variables
- **Fully responsive design** optimized for all screen sizes
## The Future
### Performance & User Experience
- **Smart loading system**:
- Connection-aware loading states that adapt to network speed
- Skeleton loaders that match content layout during loading
- Extended loading fallback UI for slow connections
- Navigation API integration for accurate loading states
Check out [the theme website](https://zaggonaut.dev) to see it in action!
- **Advanced search functionality**:
- Real-time content filtering across all sections
- Keyboard navigation with arrow keys and shortcuts
- Category-aware filtering for structured content
- Accessible search results with screen reader support
### Technical Implementation
- **Component architecture**:
- Reusable UI components with TypeScript interfaces
- Consistent styling patterns using CSS custom properties
- Modular structure for easy maintenance and extension
- **Accessibility features**:
- ARIA attributes for screen reader compatibility
- Keyboard navigation throughout the site
- Reduced motion support for animations
- High contrast theme options
- **Performance optimizations**:
- Optimized page transitions with minimal flicker
- Network-aware resource loading
- Efficient DOM updates using Alpine.js
- Astro's static site generation for fast initial loads
### Homelab Integration
- **Self-hosted services dashboard** with categorized listings
- **Service search and filtering** by name, category, and tags
- **Visual indicators** for service status and information
## The Technology Stack
- **Framework**: Astro 5.6
- **Languages**: TypeScript, JavaScript
- **Styling**: TailwindCSS 4.1, Custom CSS Variables
- **Interactivity**: Alpine.js
- **Build Tools**: Vite, npm/pnpm
- **Deployment**: Self-hosted

View File

@ -13,6 +13,17 @@ html.theme-loaded body {
transition: background-color 0.3s ease, color 0.3s ease;
}
/* Hide elements with x-cloak until Alpine.js is loaded */
[x-cloak] {
display: none !important;
}
/* Add keyboard focus styling for keyboard navigation */
.keyboard-focus {
outline: 2px solid var(--color-zag-accent-dark);
outline-offset: 2px;
}
@font-face {
font-family: "Literata Variable";
font-style: normal;