Compare commits
3 Commits
6e392cb8bf
...
6497f5dfb8
Author | SHA1 | Date | |
---|---|---|---|
6497f5dfb8 | |||
09b838819b | |||
b8a463f009 |
@ -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>
|
||||
))}
|
||||
|
33
src/components/ArticleSnippetSkeleton.astro
Normal file
33
src/components/ArticleSnippetSkeleton.astro
Normal 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>
|
@ -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>
|
||||
))
|
||||
|
43
src/components/ProjectSnippetSkeleton.astro
Normal file
43
src/components/ProjectSnippetSkeleton.astro
Normal 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>
|
@ -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);
|
||||
});
|
||||
},
|
||||
|
224
src/components/common/LoadingOverlay.astro
Normal file
224
src/components/common/LoadingOverlay.astro
Normal 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>
|
30
src/components/common/ServiceCardSkeleton.astro
Normal file
30
src/components/common/ServiceCardSkeleton.astro
Normal 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>
|
134
src/components/common/SkeletonLoader.astro
Normal file
134
src/components/common/SkeletonLoader.astro
Normal 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>
|
@ -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>
|
||||
|
@ -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 />
|
||||
|
@ -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>
|
||||
))
|
||||
|
@ -28,29 +28,50 @@ export const processContentInDir = async <T extends object, K>(
|
||||
.filter((file: string) => file.endsWith(".md"))
|
||||
.map((file) => file.split(".")[0]);
|
||||
const readMdFileContent = async (file: string) => {
|
||||
if (contentType === "projects") {
|
||||
const content = import.meta
|
||||
.glob(`/src/pages/projects/*.md`)
|
||||
[`/src/pages/projects/${file}.md`]();
|
||||
const data = (await content) as {
|
||||
frontmatter: T;
|
||||
file: string;
|
||||
url: string;
|
||||
};
|
||||
return processFn(data);
|
||||
} else {
|
||||
const content = import.meta
|
||||
.glob(`/src/pages/blog/*.md`)
|
||||
[`/src/pages/blog/${file}.md`]();
|
||||
const data = (await content) as {
|
||||
frontmatter: T;
|
||||
file: string;
|
||||
url: string;
|
||||
};
|
||||
return processFn(data);
|
||||
try {
|
||||
if (contentType === "projects") {
|
||||
const content = import.meta
|
||||
.glob(`/src/pages/projects/*.md`)
|
||||
[`/src/pages/projects/${file}.md`]();
|
||||
const data = (await content) as {
|
||||
frontmatter: T;
|
||||
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
|
||||
.glob(`/src/pages/blog/*.md`)
|
||||
[`/src/pages/blog/${file}.md`]();
|
||||
const data = (await content) as {
|
||||
frontmatter: T;
|
||||
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[];
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -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?
|
||||
|
@ -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() : '';
|
||||
|
@ -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} />
|
||||
))}
|
||||
|
@ -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() : '';
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
|
Loading…
x
Reference in New Issue
Block a user