Additional UI Enhancements and Update Project Page for this Site
This commit is contained in:
parent
6e392cb8bf
commit
b8a463f009
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>
|
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: '',
|
searchQuery: '',
|
||||||
hasResults: true,
|
hasResults: true,
|
||||||
visibleCount: 0,
|
visibleCount: 0,
|
||||||
|
loading: false, // Start with loading state false - the LoadingManager will control this
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
// Initialize the visible count
|
// Initialize the visible count
|
||||||
this.visibleCount = document.querySelectorAll(contentSelector).length;
|
this.visibleCount = document.querySelectorAll(contentSelector).length;
|
||||||
this.setupWatchers();
|
this.setupWatchers();
|
||||||
this.setupKeyboardShortcuts();
|
this.setupKeyboardShortcuts();
|
||||||
|
|
||||||
|
// Handle theme changes
|
||||||
|
window.addEventListener('theme-changed', () => {
|
||||||
|
this.filterContent(this.searchQuery);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
setupWatchers() {
|
setupWatchers() {
|
||||||
this.$watch('searchQuery', (query) => {
|
this.$watch('searchQuery', (query) => {
|
||||||
|
// Filter content immediately - no artificial delay
|
||||||
this.filterContent(query);
|
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>
|
@ -2,6 +2,7 @@
|
|||||||
import Footer from "../components/Footer.astro";
|
import Footer from "../components/Footer.astro";
|
||||||
import Header from "../components/Header.astro";
|
import Header from "../components/Header.astro";
|
||||||
import SearchScript from "../components/SearchScript.astro";
|
import SearchScript from "../components/SearchScript.astro";
|
||||||
|
import LoadingOverlay from "../components/common/LoadingOverlay.astro";
|
||||||
import "../styles/global.css";
|
import "../styles/global.css";
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -51,6 +52,9 @@ import "../styles/global.css";
|
|||||||
<slot name="head" />
|
<slot name="head" />
|
||||||
</head>
|
</head>
|
||||||
<body class="zag-bg zag-text zag-transition font-mono">
|
<body class="zag-bg zag-text zag-transition font-mono">
|
||||||
|
<!-- Loading overlay for long loading times -->
|
||||||
|
<LoadingOverlay />
|
||||||
|
|
||||||
<Header />
|
<Header />
|
||||||
<main>
|
<main>
|
||||||
<slot />
|
<slot />
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
import { GLOBAL } from "../../lib/variables";
|
import { GLOBAL } from "../../lib/variables";
|
||||||
import Layout from "../../layouts/Layout.astro";
|
import Layout from "../../layouts/Layout.astro";
|
||||||
import ArticleSnippet from "../../components/ArticleSnippet.astro";
|
import ArticleSnippet from "../../components/ArticleSnippet.astro";
|
||||||
|
import ArticleSnippetSkeleton from "../../components/ArticleSnippetSkeleton.astro";
|
||||||
import Section from "../../components/common/Section.astro";
|
import Section from "../../components/common/Section.astro";
|
||||||
import SearchBar from "../../components/common/SearchBar.astro";
|
import SearchBar from "../../components/common/SearchBar.astro";
|
||||||
import { articles } from "../../lib/list";
|
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 -->
|
<!-- Search functionality is provided by search-client.js -->
|
||||||
|
|
||||||
<Section class="my-8">
|
<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 -->
|
<!-- Search container - positioned at the top -->
|
||||||
<div class="mb-4 pt-0">
|
<div class="mb-4 pt-0">
|
||||||
<div class="w-full">
|
<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>
|
<p class="text-xl font-semibold zag-text">No Articles Found</p>
|
||||||
</div>
|
</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) => {
|
articles.map((article) => {
|
||||||
const articleTags = article.tags ? article.tags.join(' ').toLowerCase() : '';
|
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 Section from "../../components/common/Section.astro";
|
||||||
import SearchBar from "../../components/common/SearchBar.astro";
|
import SearchBar from "../../components/common/SearchBar.astro";
|
||||||
import CategorySection from "../../components/homelab/CategorySection.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 { services } from "./services.ts";
|
||||||
import { initializeSearch } from "../../components/common/searchUtils.js";
|
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 -->
|
<!-- Search functionality is provided by search-client.js -->
|
||||||
|
|
||||||
<Section class="my-8">
|
<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 -->
|
<!-- Search container - positioned at the top -->
|
||||||
<div class="mb-4 pt-0">
|
<div class="mb-4 pt-0">
|
||||||
<div class="w-full">
|
<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>
|
<p class="text-xl font-semibold zag-text">No Results</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Service categories -->
|
<!-- Loading skeleton -->
|
||||||
<div id="app-list">
|
<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]) => (
|
{Object.entries(services).map(([category, apps]) => (
|
||||||
<CategorySection category={category} apps={apps} />
|
<CategorySection category={category} apps={apps} />
|
||||||
))}
|
))}
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
import { projects } from "../../lib/list";
|
import { projects } from "../../lib/list";
|
||||||
import Section from "../../components/common/Section.astro";
|
import Section from "../../components/common/Section.astro";
|
||||||
import ProjectSnippet from "../../components/ProjectSnippet.astro";
|
import ProjectSnippet from "../../components/ProjectSnippet.astro";
|
||||||
|
import ProjectSnippetSkeleton from "../../components/ProjectSnippetSkeleton.astro";
|
||||||
import SearchBar from "../../components/common/SearchBar.astro";
|
import SearchBar from "../../components/common/SearchBar.astro";
|
||||||
import Layout from "../../layouts/Layout.astro";
|
import Layout from "../../layouts/Layout.astro";
|
||||||
import { GLOBAL } from "../../lib/variables";
|
import { GLOBAL } from "../../lib/variables";
|
||||||
@ -37,7 +38,7 @@ import { initializeSearch } from "../../components/common/searchUtils.js";
|
|||||||
<!-- Search functionality is provided by search-client.js -->
|
<!-- Search functionality is provided by search-client.js -->
|
||||||
|
|
||||||
<Section class="py-4 my-8">
|
<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 -->
|
<!-- Search container - positioned at the top -->
|
||||||
<div class="mb-4 pt-0">
|
<div class="mb-4 pt-0">
|
||||||
<div class="w-full">
|
<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>
|
<p class="text-xl font-semibold zag-text">No Projects Found</p>
|
||||||
</div>
|
</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) => {
|
projects.map((project) => {
|
||||||
const projectTags = project.tags ? project.tags.join(' ').toLowerCase() : '';
|
const projectTags = project.tags ? project.tags.join(' ').toLowerCase() : '';
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
---
|
---
|
||||||
layout: ../../layouts/ProjectLayout.astro
|
layout: ../../layouts/ProjectLayout.astro
|
||||||
title: This Website
|
title: This Website
|
||||||
description: My personal blog and portfolio, built using TypeScript, TailwindCSS, and Astro.
|
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", "web-development"]
|
tags: ["astro", "typescript", "tailwindcss", "alpinejs", "responsive-design", "accessibility", "dark-mode", "gruvbox-theme"]
|
||||||
githubUrl: https://code.justin.deal
|
githubUrl: https://code.justin.deal
|
||||||
timestamp: 2025-02-24T02:39:03+00:00
|
timestamp: 2025-02-24T02:39:03+00:00
|
||||||
featured: true
|
featured: true
|
||||||
@ -11,17 +11,59 @@ filename: zaggonaut
|
|||||||
|
|
||||||
## The Details
|
## 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
|
## The Features
|
||||||
|
|
||||||
- Dark & light mode
|
### Content & Navigation
|
||||||
- Customizable colors
|
- **Multi-purpose platform**: Blog, portfolio, and homelab dashboard integration
|
||||||
- 100 / 100 Lighthouse score
|
- **Dark & light mode** with smooth transitions and flash prevention
|
||||||
- Fully accessible
|
- **Gruvbox-inspired color scheme** with customizable theme variables
|
||||||
- Fully responsive
|
- **Fully responsive design** optimized for all screen sizes
|
||||||
- Type-safe
|
|
||||||
|
|
||||||
## 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;
|
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-face {
|
||||||
font-family: "Literata Variable";
|
font-family: "Literata Variable";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
|
Loading…
x
Reference in New Issue
Block a user