Compare commits

...

6 Commits

Author SHA1 Message Date
ca9cd27acd Add several options for theme selection
Some checks failed
Build and Deploy / build (push) Failing after 29s
2025-05-03 14:37:23 -07:00
fe566f9e1a Add performance optimizations 2025-05-03 14:20:57 -07:00
9179145830 Update Animations and UI feedback 2025-05-03 14:06:52 -07:00
fb7ef4c464 Update Style Controls 2025-05-03 13:35:55 -07:00
583461d25c Update footer styling so socials are above links 2025-05-03 13:24:56 -07:00
4ea6a4daa7 Add metadata component and move sizing component beneath search bar 2025-05-03 13:19:10 -07:00
55 changed files with 3414 additions and 641 deletions

View File

@ -1 +1 @@
[["Map",1,2],"meta::meta",["Map",3,4,5,6],"astro-version","5.7.5","astro-config-digest","{\"root\":{},\"srcDir\":{},\"publicDir\":{},\"outDir\":{},\"cacheDir\":{},\"site\":\"https://justin.deal\",\"compressHTML\":true,\"base\":\"/\",\"trailingSlash\":\"ignore\",\"output\":\"static\",\"scopedStyleStrategy\":\"attribute\",\"build\":{\"format\":\"directory\",\"client\":{},\"server\":{},\"assets\":\"_astro\",\"serverEntry\":\"entry.mjs\",\"redirects\":true,\"inlineStylesheets\":\"auto\",\"concurrency\":1},\"server\":{\"open\":false,\"host\":false,\"port\":4321,\"streaming\":true,\"allowedHosts\":[]},\"redirects\":{},\"image\":{\"endpoint\":{\"route\":\"/_image\"},\"service\":{\"entrypoint\":\"astro/assets/services/sharp\",\"config\":{}},\"domains\":[],\"remotePatterns\":[]},\"devToolbar\":{\"enabled\":true},\"markdown\":{\"syntaxHighlight\":{\"type\":\"shiki\",\"excludeLangs\":[\"math\"]},\"shikiConfig\":{\"langs\":[],\"langAlias\":{},\"theme\":\"github-dark\",\"themes\":{},\"wrap\":false,\"transformers\":[]},\"remarkPlugins\":[],\"rehypePlugins\":[],\"remarkRehype\":{},\"gfm\":true,\"smartypants\":true},\"security\":{\"checkOrigin\":true},\"env\":{\"schema\":{},\"validateSecrets\":false},\"experimental\":{\"clientPrerender\":false,\"contentIntellisense\":false,\"responsiveImages\":false,\"headingIdCompat\":false,\"preserveScriptOrder\":false},\"legacy\":{\"collections\":false}}"]
[["Map",1,2],"meta::meta",["Map",3,4,5,6],"astro-version","5.7.5","astro-config-digest","{\"root\":{},\"srcDir\":{},\"publicDir\":{},\"outDir\":{},\"cacheDir\":{},\"site\":\"https://justin.deal\",\"compressHTML\":true,\"base\":\"/\",\"trailingSlash\":\"ignore\",\"output\":\"static\",\"scopedStyleStrategy\":\"attribute\",\"build\":{\"format\":\"directory\",\"client\":{},\"server\":{},\"assets\":\"_astro\",\"serverEntry\":\"entry.mjs\",\"redirects\":true,\"inlineStylesheets\":\"auto\",\"concurrency\":1},\"server\":{\"open\":false,\"host\":false,\"port\":4321,\"streaming\":true,\"allowedHosts\":[]},\"redirects\":{},\"image\":{\"endpoint\":{\"route\":\"/_image\"},\"service\":{\"entrypoint\":\"astro/assets/services/sharp\",\"config\":{\"quality\":80,\"formats\":[\"webp\",\"avif\",\"png\",\"jpg\"]}},\"domains\":[],\"remotePatterns\":[]},\"devToolbar\":{\"enabled\":true},\"markdown\":{\"syntaxHighlight\":{\"type\":\"shiki\",\"excludeLangs\":[\"math\"]},\"shikiConfig\":{\"langs\":[],\"langAlias\":{},\"theme\":\"github-dark\",\"themes\":{},\"wrap\":false,\"transformers\":[]},\"remarkPlugins\":[],\"rehypePlugins\":[],\"remarkRehype\":{},\"gfm\":true,\"smartypants\":true},\"security\":{\"checkOrigin\":true},\"env\":{\"schema\":{},\"validateSecrets\":false},\"experimental\":{\"clientPrerender\":false,\"contentIntellisense\":false,\"responsiveImages\":false,\"headingIdCompat\":false,\"preserveScriptOrder\":false},\"legacy\":{\"collections\":false}}"]

1
.astro/types.d.ts vendored
View File

@ -1 +1,2 @@
/// <reference types="astro/client" />
/// <reference path="content.d.ts" />

View File

@ -5,7 +5,60 @@ import tailwindcss from "@tailwindcss/vite";
// https://astro.build/config
export default defineConfig({
site: 'https://justin.deal',
// Performance optimizations
compressHTML: true,
// Build optimizations
build: {
inlineStylesheets: 'auto', // Inline small stylesheets for better performance
},
// Image optimizations
image: {
service: {
entrypoint: 'astro/assets/services/sharp',
config: {
quality: 80, // Default image quality
formats: ['webp', 'avif', 'png', 'jpg'], // Supported formats in order of preference
},
},
},
// Vite configuration
vite: {
plugins: [tailwindcss()],
// Build optimizations
build: {
// Enable chunk splitting
cssCodeSplit: true,
// Optimize chunks
rollupOptions: {
output: {
// Customize chunk naming
manualChunks: {
// Group Alpine.js and related code
alpine: ['alpinejs'],
},
},
},
},
// Optimize dependencies
optimizeDeps: {
include: ['alpinejs'],
},
// CSS optimization
css: {
devSourcemap: true,
},
// Enable server-side rendering optimizations
ssr: {
noExternal: ['@astrojs/tailwind'],
},
},
});

View File

@ -1,25 +1,25 @@
{
"hash": "7113b21e",
"configHash": "2cd4a4ea",
"hash": "22066863",
"configHash": "21423e47",
"lockfileHash": "53cd0e09",
"browserHash": "f4d46c12",
"browserHash": "ad44e8cf",
"optimized": {
"astro > cssesc": {
"src": "../../.pnpm/cssesc@3.0.0/node_modules/cssesc/cssesc.js",
"file": "astro___cssesc.js",
"fileHash": "dc615560",
"fileHash": "35f908c6",
"needsInterop": true
},
"astro > aria-query": {
"src": "../../.pnpm/aria-query@5.3.2/node_modules/aria-query/lib/index.js",
"file": "astro___aria-query.js",
"fileHash": "53d05d83",
"fileHash": "13f7590c",
"needsInterop": true
},
"astro > axobject-query": {
"src": "../../.pnpm/axobject-query@4.1.0/node_modules/axobject-query/lib/index.js",
"file": "astro___axobject-query.js",
"fileHash": "35e7ec58",
"fileHash": "59c979c2",
"needsInterop": true
}
},

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

BIN
public/favicons/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

252
public/offline.html Normal file
View File

@ -0,0 +1,252 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Offline | Justin Deal</title>
<style>
:root {
--color-bg: #fbf1c7;
--color-text: #3c3836;
--color-accent: #d65d0e;
--color-muted: #7c6f64;
--font-mono: 'IBM Plex Mono', monospace;
--font-display: 'Press Start 2P', monospace;
}
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #282828;
--color-text: #ebdbb2;
--color-accent: #fe8019;
--color-muted: #a89984;
}
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--font-mono);
background-color: var(--color-bg);
color: var(--color-text);
line-height: 1.6;
display: flex;
flex-direction: column;
min-height: 100vh;
padding: 2rem;
}
main {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
max-width: 800px;
margin: 0 auto;
}
h1 {
font-family: var(--font-display);
font-size: 2rem;
margin-bottom: 1rem;
color: var(--color-accent);
}
p {
margin-bottom: 1.5rem;
font-size: 1.1rem;
}
.offline-icon {
font-size: 4rem;
margin-bottom: 2rem;
animation: pulse 2s infinite;
}
.button {
display: inline-block;
background-color: var(--color-accent);
color: var(--color-bg);
padding: 0.75rem 1.5rem;
border-radius: 0.25rem;
text-decoration: none;
font-weight: bold;
transition: transform 0.2s, box-shadow 0.2s;
cursor: pointer;
margin-top: 1rem;
}
.button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.cached-pages {
margin-top: 2rem;
text-align: left;
width: 100%;
max-width: 400px;
}
.cached-pages h2 {
font-size: 1.2rem;
margin-bottom: 1rem;
color: var(--color-accent);
}
.cached-pages ul {
list-style: none;
}
.cached-pages li {
margin-bottom: 0.5rem;
}
.cached-pages a {
color: var(--color-text);
text-decoration: none;
border-bottom: 1px solid var(--color-accent);
padding-bottom: 2px;
}
.cached-pages a:hover {
color: var(--color-accent);
}
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.6;
}
100% {
opacity: 1;
}
}
</style>
</head>
<body>
<main>
<div class="offline-icon">📶</div>
<h1>You're Offline</h1>
<p>It looks like you've lost your internet connection. Some pages may still be available if you've visited them before.</p>
<button class="button" id="retry-button">Retry Connection</button>
<div class="cached-pages" id="cached-pages">
<h2>Available Pages</h2>
<p>Loading cached pages...</p>
<ul id="cached-pages-list"></ul>
</div>
</main>
<script>
// Check if we're actually offline
function checkConnection() {
return navigator.onLine;
}
// Update UI based on connection status
function updateConnectionStatus() {
if (checkConnection()) {
// We're back online, reload the page
window.location.reload();
} else {
// Still offline
document.querySelector('.offline-icon').textContent = '📶';
document.querySelector('h1').textContent = "You're Offline";
}
}
// Listen for online/offline events
window.addEventListener('online', updateConnectionStatus);
window.addEventListener('offline', updateConnectionStatus);
// Retry button
document.getElementById('retry-button').addEventListener('click', () => {
document.querySelector('.offline-icon').textContent = '🔄';
document.querySelector('h1').textContent = "Checking Connection...";
// Try to fetch the homepage
fetch('/')
.then(() => {
// If successful, we're online
window.location.reload();
})
.catch(() => {
// Still offline
updateConnectionStatus();
});
});
// List cached pages if service worker and caches are available
if ('caches' in window && 'serviceWorker' in navigator) {
caches.open('justin-deal-v1')
.then(cache => {
return cache.keys()
.then(requests => {
const cachedPagesList = document.getElementById('cached-pages-list');
if (requests.length === 0) {
document.getElementById('cached-pages').innerHTML = '<h2>No Cached Pages Available</h2><p>Try visiting some pages when you\'re back online.</p>';
return;
}
// Filter for HTML pages
const htmlRequests = requests.filter(request => {
const url = new URL(request.url);
return url.pathname === '/' ||
url.pathname.endsWith('.html') ||
!url.pathname.includes('.');
});
// Sort by URL
htmlRequests.sort((a, b) => {
return new URL(a.url).pathname.localeCompare(new URL(b.url).pathname);
});
// Create list items
const listItems = htmlRequests.map(request => {
const url = new URL(request.url);
let pageName = url.pathname === '/' ? 'Home' : url.pathname
.replace(/\/$/, '')
.replace(/^\//, '')
.replace(/\.html$/, '')
.split('/')
.pop()
.replace(/-/g, ' ');
// Capitalize first letter of each word
pageName = pageName
.split(' ')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
return `<li><a href="${url.pathname}">${pageName}</a></li>`;
});
if (listItems.length === 0) {
document.getElementById('cached-pages').innerHTML = '<h2>No Cached Pages Available</h2><p>Try visiting some pages when you\'re back online.</p>';
return;
}
cachedPagesList.innerHTML = listItems.join('');
document.querySelector('#cached-pages p').style.display = 'none';
});
})
.catch(error => {
console.error('Error accessing cache:', error);
document.getElementById('cached-pages').innerHTML = '<h2>Could Not Access Cache</h2><p>There was an error accessing the cached pages.</p>';
});
} else {
document.getElementById('cached-pages').innerHTML = '<h2>Cache Not Available</h2><p>Your browser does not support caching or service workers.</p>';
}
</script>
</body>
</html>

View File

225
public/service-worker.js Normal file
View File

@ -0,0 +1,225 @@
/**
* Service Worker for justin.deal
* Provides caching and offline support
*/
const CACHE_NAME = 'justin-deal-v1';
// Assets to cache immediately on service worker install
const PRECACHE_ASSETS = [
'/',
'/index.html',
'/favicon.svg',
'/site.webmanifest',
'/favicons/favicon.png',
'/favicons/apple-touch-icon.png',
'/favicons/favicon-16x16.png',
'/favicons/favicon-32x32.png'
];
// Cache strategies
const CACHE_STRATEGIES = {
// Cache first, falling back to network
CACHE_FIRST: 'cache-first',
// Network first, falling back to cache
NETWORK_FIRST: 'network-first',
// Cache only (no network request)
CACHE_ONLY: 'cache-only',
// Network only (no cache lookup)
NETWORK_ONLY: 'network-only',
// Stale while revalidate (serve from cache, update cache in background)
STALE_WHILE_REVALIDATE: 'stale-while-revalidate'
};
// Route patterns and their corresponding cache strategies
const ROUTE_STRATEGIES = [
// HTML pages - network first
{
pattern: /\.html$|\/$/,
strategy: CACHE_STRATEGIES.NETWORK_FIRST
},
// CSS and JS - stale while revalidate
{
pattern: /\.(css|js)$/,
strategy: CACHE_STRATEGIES.STALE_WHILE_REVALIDATE
},
// Images - cache first
{
pattern: /\.(jpe?g|png|gif|svg|webp|avif)$/,
strategy: CACHE_STRATEGIES.CACHE_FIRST
},
// Fonts - cache first
{
pattern: /\.(woff2?|ttf|otf|eot)$/,
strategy: CACHE_STRATEGIES.CACHE_FIRST
},
// API requests - network first
{
pattern: /\/api\//,
strategy: CACHE_STRATEGIES.NETWORK_FIRST
}
];
// Determine cache strategy for a given URL
function getStrategyForUrl(url) {
const matchedRoute = ROUTE_STRATEGIES.find(route => route.pattern.test(url));
return matchedRoute ? matchedRoute.strategy : CACHE_STRATEGIES.NETWORK_FIRST;
}
// Install event - precache critical assets
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(PRECACHE_ASSETS))
.then(() => self.skipWaiting())
);
});
// Activate event - clean up old caches
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames
.filter(cacheName => cacheName !== CACHE_NAME)
.map(cacheName => caches.delete(cacheName))
);
}).then(() => self.clients.claim())
);
});
// Helper function to handle network-first strategy
async function networkFirstStrategy(request) {
try {
// Try network first
const networkResponse = await fetch(request);
// If successful, clone and cache the response
if (networkResponse.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
// If network fails, try cache
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
// If no cache, throw error
throw error;
}
}
// Helper function to handle cache-first strategy
async function cacheFirstStrategy(request) {
// Try cache first
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
// If not in cache, get from network
const networkResponse = await fetch(request);
// Cache the response for future
if (networkResponse.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, networkResponse.clone());
}
return networkResponse;
}
// Helper function to handle stale-while-revalidate strategy
async function staleWhileRevalidateStrategy(request) {
// Try to get from cache
const cachedResponse = await caches.match(request);
// Fetch from network to update cache (don't await)
const fetchPromise = fetch(request)
.then(networkResponse => {
if (networkResponse.ok) {
const cache = caches.open(CACHE_NAME);
cache.then(cache => cache.put(request, networkResponse.clone()));
}
return networkResponse;
})
.catch(error => {
console.error('Failed to fetch and update cache:', error);
});
// Return cached response immediately if available
return cachedResponse || fetchPromise;
}
// Fetch event - handle all fetch requests
self.addEventListener('fetch', event => {
// Skip non-GET requests and browser extensions
if (event.request.method !== 'GET' ||
event.request.url.startsWith('chrome-extension://') ||
event.request.url.includes('extension://')) {
return;
}
// Get the appropriate strategy for this URL
const strategy = getStrategyForUrl(event.request.url);
// Apply the selected strategy
switch (strategy) {
case CACHE_STRATEGIES.NETWORK_FIRST:
event.respondWith(networkFirstStrategy(event.request));
break;
case CACHE_STRATEGIES.CACHE_FIRST:
event.respondWith(cacheFirstStrategy(event.request));
break;
case CACHE_STRATEGIES.STALE_WHILE_REVALIDATE:
event.respondWith(staleWhileRevalidateStrategy(event.request));
break;
case CACHE_STRATEGIES.CACHE_ONLY:
event.respondWith(caches.match(event.request));
break;
case CACHE_STRATEGIES.NETWORK_ONLY:
event.respondWith(fetch(event.request));
break;
default:
// Default to network first
event.respondWith(networkFirstStrategy(event.request));
}
});
// Handle offline fallback
self.addEventListener('fetch', event => {
// Only handle HTML navigation requests that fail
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request)
.catch(() => {
// If fetch fails, return the offline page
return caches.match('/offline.html')
.then(response => {
return response || new Response(
'<html><body><h1>Offline</h1><p>You are currently offline.</p></body></html>',
{
headers: { 'Content-Type': 'text/html' }
}
);
});
})
);
}
});
// Listen for messages from the client
self.addEventListener('message', event => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});

20
public/site.webmanifest Normal file
View File

@ -0,0 +1,20 @@
{
"name": "Justin Deal",
"short_name": "Justin Deal",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#282828",
"background_color": "#ebdbb2",
"display": "standalone",
"start_url": "/"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 956 KiB

View File

@ -9,43 +9,39 @@ const year = date.getFullYear();
<footer class="mt-16 mb-8">
<Section class="mb-4">
<div
class="zag-border-b zag-transition pb-4 flex flex-col sm:flex-row sm:flex-nowrap gap-8 sm:items-center"
>
<ul class="flex flex-row gap-2">
{Object.entries(GLOBAL.menu).map((i) => (
<li>
<Anchor url={i[1]}>{i[0]}</Anchor>
</li>
))}
</ul>
<div class="flex flex-row sm:flex-row gap-4">
{GLOBAL.githubProfile && (<Anchor url={GLOBAL.githubProfile} aria-label="GitHub Profile">
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
>
<path
class="zag-fill zag-transition"
d="M12 2A10 10 0 0 0 2 12c0 4.42 2.87 8.17 6.84 9.5c.5.08.66-.23.66-.5v-1.69c-2.77.6-3.36-1.34-3.36-1.34c-.46-1.16-1.11-1.47-1.11-1.47c-.91-.62.07-.6.07-.6c1 .07 1.53 1.03 1.53 1.03c.87 1.52 2.34 1.07 2.91.83c.09-.65.35-1.09.63-1.34c-2.22-.25-4.55-1.11-4.55-4.92c0-1.11.38-2 1.03-2.71c-.1-.25-.45-1.29.1-2.64c0 0 .84-.27 2.75 1.02c.79-.22 1.65-.33 2.5-.33s1.71.11 2.5.33c1.91-1.29 2.75-1.02 2.75-1.02c.55 1.35.2 2.39.1 2.64c.65.71 1.03 1.6 1.03 2.71c0 3.82-2.34 4.66-4.57 4.91c.36.31.69.92.69 1.85V21c0 .27.16.59.67.5C19.14 20.16 22 16.42 22 12A10 10 0 0 0 12 2"
></path>
</svg>
</Anchor>)}
{GLOBAL.giteaProfile && (<Anchor url={GLOBAL.giteaProfile} aria-label="Gitea Profile">
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
>
<path
class="zag-fill zag-transition"
d="M12 2A10 10 0 0 0 2 12c0 4.42 2.87 8.17 6.84 9.5c.5.08.66-.23.66-.5v-1.69c-2.77.6-3.36-1.34-3.36-1.34c-.46-1.16-1.11-1.47-1.11-1.47c-.91-.62.07-.6.07-.6c1 .07 1.53 1.03 1.53 1.03c.87 1.52 2.34 1.07 2.91.83c.09-.65.35-1.09.63-1.34c-2.22-.25-4.55-1.11-4.55-4.92c0-1.11.38-2 1.03-2.71c-.1-.25-.45-1.29.1-2.64c0 0 .84-.27 2.75 1.02c.79-.22 1.65-.33 2.5-.33s1.71.11 2.5.33c1.91-1.29 2.75-1.02 2.75-1.02c.55 1.35.2 2.39.1 2.64c.65.71 1.03 1.6 1.03 2.71c0 3.82-2.34 4.66-4.57 4.91c.36.31.69.92.69 1.85V21c0 .27.16.59.67.5C19.14 20.16 22 16.42 22 12A10 10 0 0 0 12 2"
></path>
</svg>
</Anchor>)}
<div class="flex flex-col gap-6">
<!-- Social icons row - now above the links -->
<div class="flex justify-center gap-4">
{GLOBAL.githubProfile && (
<Anchor url={GLOBAL.githubProfile} aria-label="GitHub Profile">
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
>
<path
class="zag-fill zag-transition"
d="M12 2A10 10 0 0 0 2 12c0 4.42 2.87 8.17 6.84 9.5c.5.08.66-.23.66-.5v-1.69c-2.77.6-3.36-1.34-3.36-1.34c-.46-1.16-1.11-1.47-1.11-1.47c-.91-.62.07-.6.07-.6c1 .07 1.53 1.03 1.53 1.03c.87 1.52 2.34 1.07 2.91.83c.09-.65.35-1.09.63-1.34c-2.22-.25-4.55-1.11-4.55-4.92c0-1.11.38-2 1.03-2.71c-.1-.25-.45-1.29.1-2.64c0 0 .84-.27 2.75 1.02c.79-.22 1.65-.33 2.5-.33s1.71.11 2.5.33c1.91-1.29 2.75-1.02 2.75-1.02c.55 1.35.2 2.39.1 2.64c.65.71 1.03 1.6 1.03 2.71c0 3.82-2.34 4.66-4.57 4.91c.36.31.69.92.69 1.85V21c0 .27.16.59.67.5C19.14 20.16 22 16.42 22 12A10 10 0 0 0 12 2"
></path>
</svg>
</Anchor>
)}
{GLOBAL.giteaProfile && (
<Anchor url={GLOBAL.giteaProfile} aria-label="Gitea Profile">
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
>
<path
class="zag-fill zag-transition"
d="M12 2A10 10 0 0 0 2 12c0 4.42 2.87 8.17 6.84 9.5c.5.08.66-.23.66-.5v-1.69c-2.77.6-3.36-1.34-3.36-1.34c-.46-1.16-1.11-1.47-1.11-1.47c-.91-.62.07-.6.07-.6c1 .07 1.53 1.03 1.53 1.03c.87 1.52 2.34 1.07 2.91.83c.09-.65.35-1.09.63-1.34c-2.22-.25-4.55-1.11-4.55-4.92c0-1.11.38-2 1.03-2.71c-.1-.25-.45-1.29.1-2.64c0 0 .84-.27 2.75 1.02c.79-.22 1.65-.33 2.5-.33s1.71.11 2.5.33c1.91-1.29 2.75-1.02 2.75-1.02c.55 1.35.2 2.39.1 2.64c.65.71 1.03 1.6 1.03 2.71c0 3.82-2.34 4.66-4.57 4.91c.36.31.69.92.69 1.85V21c0 .27.16.59.67.5C19.14 20.16 22 16.42 22 12A10 10 0 0 0 12 2"
></path>
</svg>
</Anchor>
)}
{GLOBAL.linkedinProfile && (
<Anchor url={GLOBAL.linkedinProfile} aria-label="LinkedIn Profile">
<svg
@ -60,7 +56,19 @@ const year = date.getFullYear();
d="M19 0h-14c-2.76 0-5 2.24-5 5v14c0 2.76 2.24 5 5 5h14c2.76 0 5-2.24 5-5v-14c0-2.76-2.24-5-5-5zm-11.12 19h-3.08v-9h3.08v9zm-1.54-10.29c-.99 0-1.79-.8-1.79-1.79s.8-1.79 1.79-1.79 1.79.8 1.79 1.79-.8 1.79-1.79 1.79zm13.16 10.29h-3.08v-4.89c0-1.16-.02-2.64-1.61-2.64s-1.86 1.26-1.86 2.57v4.96h-3.08v-9h2.96v1.23h.04c.41-.78 1.4-1.6 2.88-1.6 3.08 0 3.65 2.03 3.65 4.66v4.71z"
></path>
</svg>
</Anchor>)}
</Anchor>
)}
</div>
<!-- Navigation links row - now below the icons -->
<div class="zag-border-b zag-transition pb-4">
<ul class="flex flex-wrap justify-center gap-4 sm:gap-6">
{Object.entries(GLOBAL.menu).map((i) => (
<li>
<Anchor url={i[1]}>{i[0]}</Anchor>
</li>
))}
</ul>
</div>
</div>
</Section>

72
src/components/SEO.astro Normal file
View File

@ -0,0 +1,72 @@
---
interface Props {
title?: string;
description?: string;
image?: string;
article?: boolean;
canonicalUrl?: string;
publishedTime?: string;
modifiedTime?: string;
tags?: string[];
}
import { GLOBAL } from "../lib/variables";
const {
title = GLOBAL.username,
description = GLOBAL.longDescription,
image = `${GLOBAL.rootUrl}/assets/images/${GLOBAL.profileImage}`,
article = false,
canonicalUrl = Astro.url.href,
publishedTime,
modifiedTime,
tags = [],
} = Astro.props;
// Format the title with site name
const formattedTitle = title === GLOBAL.username
? `${title} • ${GLOBAL.shortDescription}`
: `${title} • ${GLOBAL.username}`;
---
<!-- Primary Meta Tags -->
<title>{formattedTitle}</title>
<meta name="title" content={formattedTitle} />
<meta name="description" content={description} />
<!-- Canonical URL -->
<link rel="canonical" href={canonicalUrl} />
<!-- Open Graph / Facebook -->
<meta property="og:type" content={article ? "article" : "website"} />
<meta property="og:url" content={canonicalUrl} />
<meta property="og:title" content={formattedTitle} />
<meta property="og:description" content={description} />
<meta property="og:image" content={image} />
<meta property="og:site_name" content={GLOBAL.username} />
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content={canonicalUrl} />
<meta property="twitter:title" content={formattedTitle} />
<meta property="twitter:description" content={description} />
<meta property="twitter:image" content={image} />
<!-- Article specific tags -->
{article && publishedTime && (
<meta property="article:published_time" content={publishedTime} />
)}
{article && modifiedTime && (
<meta property="article:modified_time" content={modifiedTime} />
)}
{article && tags.length > 0 && (
tags.map(tag => <meta property="article:tag" content={tag} />)
)}
<!-- Language and locale -->
<meta http-equiv="content-language" content="en" />
<meta name="language" content="English" />
<!-- Additional SEO tags -->
<meta name="robots" content="index, follow" />
<meta name="author" content={GLOBAL.username} />

View File

@ -232,7 +232,7 @@ function initializeSearch(contentSelector = '.searchable-item', options = {}) {
document.addEventListener('alpine:init', () => {
// Homelab search
window.Alpine.data('searchServices', () => {
return initializeSearch('.app-card', {
const baseSearch = initializeSearch('.app-card', {
nameAttribute: 'data-app-name',
tagsAttribute: 'data-app-tags',
categoryAttribute: 'data-app-category',
@ -241,6 +241,152 @@ document.addEventListener('alpine:init', () => {
resultCountMessage: (count) => `Found ${count} services`,
itemLabel: 'services'
});
// Add icon size slider functionality
return {
...baseSearch,
iconSizeValue: 2, // Slider value: 1=small, 2=medium, 3=large
iconSize: 'medium', // small, medium, large
viewMode: 'grid', // grid or list
displayMode: 'both', // both, image, or name
debounceTimeout: null, // For debouncing slider changes
init() {
baseSearch.init.call(this);
// Apply initial icon size, view mode, and display mode
this.applyIconSize();
this.applyViewMode();
this.applyDisplayMode();
},
// Icon size methods
setIconSize(size) {
if (typeof size === 'string') {
// Handle legacy string values (small, medium, large)
this.iconSize = size;
this.iconSizeValue = size === 'small' ? 1 : size === 'medium' ? 2 : 3;
} else {
// Handle slider numeric values
this.iconSizeValue = parseFloat(size);
// Map slider value to size name
if (this.iconSizeValue <= 1.33) {
this.iconSize = 'small';
} else if (this.iconSizeValue <= 2.33) {
this.iconSize = 'medium';
} else {
this.iconSize = 'large';
}
}
this.applyIconSize();
},
// Handle slider input with debounce
handleSliderChange(event) {
const value = event.target.value;
// Clear any existing timeout
if (this.debounceTimeout) {
clearTimeout(this.debounceTimeout);
}
// Set a new timeout
this.debounceTimeout = setTimeout(() => {
this.setIconSize(value);
}, 50); // 50ms debounce
},
applyIconSize() {
const appList = document.getElementById('app-list');
if (!appList) return;
// Remove existing size classes
appList.classList.remove('icon-size-small', 'icon-size-medium', 'icon-size-large');
// Add the new size class
appList.classList.add(`icon-size-${this.iconSize}`);
// Apply custom CSS variable for fine-grained control
appList.style.setProperty('--icon-scale', this.iconSizeValue);
},
// View mode methods
toggleViewMode() {
this.viewMode = this.viewMode === 'grid' ? 'list' : 'grid';
this.applyViewMode();
},
setViewMode(mode) {
this.viewMode = mode;
this.applyViewMode();
},
applyViewMode() {
const appList = document.getElementById('app-list');
if (!appList) return;
// Remove existing view mode classes
appList.classList.remove('view-mode-grid', 'view-mode-list');
// Add the new view mode class
appList.classList.add(`view-mode-${this.viewMode}`);
// Update all category sections
document.querySelectorAll('.category-section').forEach(section => {
const gridContainer = section.querySelector('.grid');
if (gridContainer) {
// Update grid classes based on view mode
if (this.viewMode === 'grid') {
gridContainer.classList.remove('grid-cols-1');
gridContainer.classList.add('grid-cols-2', 'sm:grid-cols-3', 'lg:grid-cols-4');
} else {
gridContainer.classList.remove('grid-cols-2', 'sm:grid-cols-3', 'lg:grid-cols-4');
gridContainer.classList.add('grid-cols-1');
}
}
});
},
// Display mode methods
setDisplayMode(mode) {
this.displayMode = mode;
this.applyDisplayMode();
},
applyDisplayMode() {
const appList = document.getElementById('app-list');
if (!appList) return;
// Remove existing display mode classes
appList.classList.remove('display-both', 'display-image-only', 'display-name-only');
// Add the new display mode class
if (this.displayMode === 'image') {
appList.classList.add('display-image-only');
} else if (this.displayMode === 'name') {
appList.classList.add('display-name-only');
} else {
appList.classList.add('display-both');
}
// Update all category sections
document.querySelectorAll('.category-section').forEach(section => {
const gridContainer = section.querySelector('.grid');
if (gridContainer) {
// Update grid classes based on view mode
if (this.viewMode === 'grid') {
gridContainer.classList.remove('grid-cols-1');
gridContainer.classList.add('grid-cols-2', 'sm:grid-cols-3', 'lg:grid-cols-4');
} else {
gridContainer.classList.remove('grid-cols-2', 'sm:grid-cols-3', 'lg:grid-cols-4');
gridContainer.classList.add('grid-cols-1');
}
}
});
}
};
});
// Blog search

View File

@ -0,0 +1,88 @@
---
interface Props {
type: 'WebSite' | 'WebPage' | 'Article' | 'Person' | 'BreadcrumbList' | 'FAQPage';
data: Record<string, any>;
}
const { type, data } = Astro.props;
// Base structured data templates
const templates = {
WebSite: {
"@context": "https://schema.org",
"@type": "WebSite",
"name": "",
"url": "",
"description": "",
"potentialAction": {
"@type": "SearchAction",
"target": "{search_term_string}",
"query-input": "required name=search_term_string"
}
},
WebPage: {
"@context": "https://schema.org",
"@type": "WebPage",
"name": "",
"description": "",
"url": "",
"isPartOf": {
"@type": "WebSite",
"name": "",
"url": ""
}
},
Article: {
"@context": "https://schema.org",
"@type": "Article",
"headline": "",
"description": "",
"image": "",
"datePublished": "",
"dateModified": "",
"author": {
"@type": "Person",
"name": "",
"url": ""
},
"publisher": {
"@type": "Person",
"name": "",
"url": ""
},
"mainEntityOfPage": {
"@type": "WebPage",
"@id": ""
}
},
Person: {
"@context": "https://schema.org",
"@type": "Person",
"name": "",
"url": "",
"jobTitle": "",
"sameAs": []
},
BreadcrumbList: {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": []
},
FAQPage: {
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": []
}
};
// Merge template with provided data
const structuredData = {
...templates[type],
...data
};
// Stringify the data for output
const jsonLd = JSON.stringify(structuredData, null, 2);
---
<script type="application/ld+json" set:html={jsonLd} />

View File

@ -0,0 +1,66 @@
<script>
// This script extends the existing Alpine.js functionality for style controls
// by adding keyboard shortcuts
// TypeScript declarations for Alpine.js
declare const Alpine: {
data: (name: string, callback?: () => any) => any;
};
document.addEventListener('alpine:init', () => {
// Extend the existing styleControls Alpine data
const originalStyleControls = Alpine.data('styleControls');
Alpine.data('styleControls', () => {
// Get the original data object
const original = typeof originalStyleControls === 'function'
? originalStyleControls()
: {};
// Return extended object with our additions
return {
// Spread original properties and methods
...original,
// Override init to add keyboard shortcuts
init() {
// Call original init if it exists
if (original.init) {
original.init.call(this);
}
// Add keyboard shortcut listener
document.addEventListener('keydown', (e) => {
// Only process if not in an input field
const target = e.target as HTMLElement;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) {
return;
}
// Alt+1, Alt+2, Alt+3 for icon sizes
if (e.altKey && e.key === '1' && this.setIconSize) {
this.setIconSize('small');
} else if (e.altKey && e.key === '2' && this.setIconSize) {
this.setIconSize('medium');
} else if (e.altKey && e.key === '3' && this.setIconSize) {
this.setIconSize('large');
}
// Alt+G to toggle grid/list view
if (e.altKey && e.key === 'g' && this.toggleViewMode) {
this.toggleViewMode();
}
// Alt+B, Alt+I, Alt+N for display modes
if (e.altKey && e.key === 'b' && this.setDisplayMode) {
this.setDisplayMode('both');
} else if (e.altKey && e.key === 'i' && this.setDisplayMode) {
this.setDisplayMode('image');
} else if (e.altKey && e.key === 'n' && this.setDisplayMode) {
this.setDisplayMode('name');
}
});
}
};
});
});
</script>

View File

@ -0,0 +1,96 @@
---
/**
* ThemeBackground component
*
* Provides subtle theme-specific background patterns that change with the theme.
* These patterns add visual interest without distracting from the content.
*
* @component
*/
---
<div class="theme-background" aria-hidden="true">
<div class="light-pattern"></div>
<div class="dark-pattern"></div>
</div>
<style>
.theme-background {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: -1;
overflow: hidden;
}
/* Light theme pattern using CSS gradients */
.light-pattern {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0.03;
transition: opacity 0.5s ease;
background-color: rgba(235, 219, 178, 0.01);
background-image:
/* Grid pattern */
linear-gradient(to right, rgba(60, 56, 54, 0.1) 1px, transparent 1px),
linear-gradient(to bottom, rgba(60, 56, 54, 0.1) 1px, transparent 1px),
/* Diagonal lines */
linear-gradient(45deg, rgba(214, 93, 14, 0.1) 25%, transparent 25%),
/* Dots pattern */
radial-gradient(rgba(184, 187, 38, 0.2) 2px, transparent 2px);
background-size:
20px 20px, /* Grid X */
20px 20px, /* Grid Y */
100px 100px, /* Diagonal lines */
40px 40px; /* Dots */
background-position:
0 0, /* Grid X */
0 0, /* Grid Y */
0 0, /* Diagonal lines */
20px 20px; /* Dots */
}
/* Dark theme pattern using CSS gradients */
.dark-pattern {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
transition: opacity 0.5s ease;
background-color: rgba(40, 40, 40, 0.01);
background-image:
/* Grid pattern */
linear-gradient(to right, rgba(235, 219, 178, 0.1) 1px, transparent 1px),
linear-gradient(to bottom, rgba(235, 219, 178, 0.1) 1px, transparent 1px),
/* Diagonal lines */
linear-gradient(45deg, rgba(254, 128, 25, 0.1) 25%, transparent 25%),
/* Dots pattern */
radial-gradient(rgba(184, 187, 38, 0.2) 2px, transparent 2px);
background-size:
20px 20px, /* Grid X */
20px 20px, /* Grid Y */
100px 100px, /* Diagonal lines */
40px 40px; /* Dots */
background-position:
0 0, /* Grid X */
0 0, /* Grid Y */
0 0, /* Diagonal lines */
20px 20px; /* Dots */
}
:global(.dark) .light-pattern {
opacity: 0;
}
:global(.dark) .dark-pattern {
opacity: 0.05;
}
</style>

View File

@ -0,0 +1,88 @@
---
/**
* ThemeScheduler component
*
* Provides automatic theme switching based on time of day.
* - Day time (7 AM to 7 PM): Light theme
* - Night time (7 PM to 7 AM): Dark theme
*
* This component only runs its logic if the user has selected the "auto" theme mode.
*
* @component
*/
---
<script is:inline>
// Time-based theme switching
(function() {
// Only run if user has selected auto mode
const themeMode = localStorage.getItem('theme-mode');
if (themeMode === 'auto') {
const checkTime = () => {
const hour = new Date().getHours();
const isDayTime = hour >= 7 && hour < 19; // 7 AM to 7 PM
const theme = isDayTime ? 'light' : 'dark';
const currentTheme = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
if (theme !== currentTheme) {
if (theme === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
localStorage.setItem('theme', theme);
// Dispatch theme changed event
window.dispatchEvent(new CustomEvent('theme-changed', {
detail: { theme, automatic: true, mode: 'auto' }
}));
}
};
// Check immediately
checkTime();
// Check every hour
setInterval(checkTime, 60 * 60 * 1000);
// Also check at specific times (7 AM and 7 PM)
const scheduleCheck = () => {
const now = new Date();
const hours = now.getHours();
const minutes = now.getMinutes();
// Calculate time until next check (either 7 AM or 7 PM)
let nextCheckHour;
if (hours < 7) {
nextCheckHour = 7; // Next check at 7 AM
} else if (hours < 19) {
nextCheckHour = 19; // Next check at 7 PM
} else {
nextCheckHour = 7; // Next check at 7 AM tomorrow
}
// Calculate milliseconds until next check
let msUntilNextCheck;
if (hours >= 19) {
// After 7 PM, next check is 7 AM tomorrow
msUntilNextCheck = ((24 - hours + 7) * 60 - minutes) * 60 * 1000;
} else {
// Before 7 PM, next check is either 7 AM or 7 PM today
msUntilNextCheck = ((nextCheckHour - hours) * 60 - minutes) * 60 * 1000;
}
// Schedule the next check
setTimeout(() => {
checkTime();
scheduleCheck(); // Schedule the next check after this one
}, msUntilNextCheck);
};
// Start the scheduling
scheduleCheck();
}
})();
</script>

View File

@ -1,46 +1,284 @@
---
/**
* Enhanced ThemeToggle component
*
* Provides a theme toggle button with a dropdown menu for additional theme options:
* - Light Mode
* - Dark Mode
* - System Preference
* - Time-Based (automatic switching based on time of day)
*
* @component
*/
---
---
<button class="border-none bg-none focus:outline-2 focus:outline-offset-2 focus:outline-zag-dark dark:focus:outline-zag-light" id="themeToggle" aria-label="Theme Toggle">
<svg
width="30px"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
name="Theme toggle"
<div class="theme-toggle-container" x-data="{ open: false }">
<button
class="theme-toggle-button zag-interactive"
id="themeToggle"
aria-label="Theme Toggle"
@click="open = !open"
>
<path
class="zag-transition fill-neutral-900 dark:fill-transparent"
fill-rule="evenodd"
d="M12 17.5a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11zm0 1.5a7 7 0 1 0 0-14 7 7 0 0 0 0 14zm12-7a.8.8 0 0 1-.8.8h-2.4a.8.8 0 0 1 0-1.6h2.4a.8.8 0 0 1 .8.8zM4 12a.8.8 0 0 1-.8.8H.8a.8.8 0 0 1 0-1.6h2.5a.8.8 0 0 1 .8.8zm16.5-8.5a.8.8 0 0 1 0 1l-1.8 1.8a.8.8 0 0 1-1-1l1.7-1.8a.8.8 0 0 1 1 0zM6.3 17.7a.8.8 0 0 1 0 1l-1.7 1.8a.8.8 0 1 1-1-1l1.7-1.8a.8.8 0 0 1 1 0zM12 0a.8.8 0 0 1 .8.8v2.5a.8.8 0 0 1-1.6 0V.8A.8.8 0 0 1 12 0zm0 20a.8.8 0 0 1 .8.8v2.4a.8.8 0 0 1-1.6 0v-2.4a.8.8 0 0 1 .8-.8zM3.5 3.5a.8.8 0 0 1 1 0l1.8 1.8a.8.8 0 1 1-1 1L3.5 4.6a.8.8 0 0 1 0-1zm14.2 14.2a.8.8 0 0 1 1 0l1.8 1.7a.8.8 0 0 1-1 1l-1.8-1.7a.8.8 0 0 1 0-1z"
></path>
<path
class="zag-transition fill-transparent dark:fill-neutral-100"
fill-rule="evenodd"
d="M16.5 6A10.5 10.5 0 0 1 4.7 16.4 8.5 8.5 0 1 0 16.4 4.7l.1 1.3zm-1.7-2a9 9 0 0 1 .2 2 9 9 0 0 1-11 8.8 9.4 9.4 0 0 1-.8-.3c-.4 0-.8.3-.7.7a10 10 0 0 0 .3.8 10 10 0 0 0 9.2 6 10 10 0 0 0 4-19.2 9.7 9.7 0 0 0-.9-.3c-.3-.1-.7.3-.6.7a9 9 0 0 1 .3.8z"
></path>
</svg>
</button>
<script is:inline>
// Theme toggle functionality - works with the flash prevention script in Layout
const handleToggleClick = () => {
const element = document.documentElement;
element.classList.toggle("dark");
const isDark = element.classList.contains("dark");
localStorage.setItem("theme", isDark ? "dark" : "light");
<!-- Sun icon for light mode -->
<svg
class="theme-icon sun-icon"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
class="zag-transition fill-neutral-900 dark:fill-transparent"
fill-rule="evenodd"
d="M12 17.5a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11zm0 1.5a7 7 0 1 0 0-14 7 7 0 0 0 0 14zm12-7a.8.8 0 0 1-.8.8h-2.4a.8.8 0 0 1 0-1.6h2.4a.8.8 0 0 1 .8.8zM4 12a.8.8 0 0 1-.8.8H.8a.8.8 0 0 1 0-1.6h2.5a.8.8 0 0 1 .8.8zm16.5-8.5a.8.8 0 0 1 0 1l-1.8 1.8a.8.8 0 0 1-1-1l1.7-1.8a.8.8 0 0 1 1 0zM6.3 17.7a.8.8 0 0 1 0 1l-1.7 1.8a.8.8 0 1 1-1-1l1.7-1.8a.8.8 0 0 1 1 0zM12 0a.8.8 0 0 1 .8.8v2.5a.8.8 0 0 1-1.6 0V.8A.8.8 0 0 1 12 0zm0 20a.8.8 0 0 1 .8.8v2.4a.8.8 0 0 1-1.6 0v-2.4a.8.8 0 0 1 .8-.8zM3.5 3.5a.8.8 0 0 1 1 0l1.8 1.8a.8.8 0 1 1-1 1L3.5 4.6a.8.8 0 0 1 0-1zm14.2 14.2a.8.8 0 0 1 1 0l1.8 1.7a.8.8 0 0 1-1 1l-1.8-1.7a.8.8 0 0 1 0-1z"
></path>
</svg>
// Dispatch a custom event that other components can listen for
window.dispatchEvent(new CustomEvent('theme-changed', {
detail: { theme: isDark ? 'dark' : 'light' }
}));
};
<!-- Moon icon for dark mode -->
<svg
class="theme-icon moon-icon"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
class="zag-transition fill-transparent dark:fill-neutral-100"
fill-rule="evenodd"
d="M16.5 6A10.5 10.5 0 0 1 4.7 16.4 8.5 8.5 0 1 0 16.4 4.7l.1 1.3zm-1.7-2a9 9 0 0 1 .2 2 9 9 0 0 1-11 8.8 9.4 9.4 0 0 1-.8-.3c-.4 0-.8.3-.7.7a10 10 0 0 0 .3.8 10 10 0 0 0 9.2 6 10 10 0 0 0 4-19.2 9.7 9.7 0 0 0-.9-.3c-.3-.1-.7.3-.6.7a9 9 0 0 1 .3.8z"
></path>
</svg>
</button>
<!-- Dropdown menu -->
<div
x-show="open"
@click.away="open = false"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 transform scale-95"
x-transition:enter-end="opacity-100 transform scale-100"
x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100 transform scale-100"
x-transition:leave-end="opacity-0 transform scale-95"
class="theme-dropdown"
x-cloak
>
<div class="theme-dropdown-content">
<button class="theme-option" id="lightTheme">
<svg class="theme-option-icon" viewBox="0 0 24 24" width="16" height="16">
<!-- Sun icon -->
<circle cx="12" cy="12" r="5" fill="currentColor"></circle>
<path d="M12 3V1M12 23v-2M3 12H1m22 0h-2M5.6 5.6L4.2 4.2m14.6 14.6l-1.4-1.4M5.6 18.4l-1.4 1.4M18.4 5.6l1.4-1.4" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path>
</svg>
<span>Light Mode</span>
</button>
<button class="theme-option" id="darkTheme">
<svg class="theme-option-icon" viewBox="0 0 24 24" width="16" height="16">
<!-- Moon icon -->
<path d="M12 3a9 9 0 1 0 9 9c0-.46-.04-.92-.1-1.36a5.389 5.389 0 0 1-4.4 2.26 5.403 5.403 0 0 1-3.14-9.8c-.44-.06-.9-.1-1.36-.1z" fill="currentColor"></path>
</svg>
<span>Dark Mode</span>
</button>
<button class="theme-option" id="systemTheme">
<svg class="theme-option-icon" viewBox="0 0 24 24" width="16" height="16">
<!-- System icon -->
<rect x="2" y="3" width="20" height="14" rx="2" stroke="currentColor" stroke-width="2" fill="none"></rect>
<path d="M8 21h8M12 17v4" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path>
</svg>
<span>System Preference</span>
</button>
<button class="theme-option" id="autoTheme">
<svg class="theme-option-icon" viewBox="0 0 24 24" width="16" height="16">
<!-- Clock icon -->
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none"></circle>
<path d="M12 6v6l4 2" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path>
</svg>
<span>Time-Based</span>
</button>
</div>
</div>
</div>
// Add event listener when the DOM is ready
<style>
.theme-toggle-container {
position: relative;
}
.theme-toggle-button {
background: none;
border: 1px solid transparent;
cursor: pointer;
padding: 6px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.theme-toggle-button:hover {
background-color: rgba(128, 128, 128, 0.1);
border-color: rgba(128, 128, 128, 0.2);
}
.theme-icon {
width: 24px;
height: 24px;
}
.theme-dropdown {
position: absolute;
top: 100%;
right: 0;
margin-top: 8px;
background-color: var(--color-zag-light);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
width: 200px;
z-index: 50;
overflow: hidden;
}
:global(.dark) .theme-dropdown {
background-color: var(--color-zag-dark);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.theme-dropdown-content {
padding: 8px;
}
.theme-option {
display: flex;
align-items: center;
width: 100%;
padding: 10px 12px;
border: none;
background: none;
text-align: left;
cursor: pointer;
border-radius: 6px;
transition: background-color 0.2s ease;
color: var(--color-zag-dark);
}
:global(.dark) .theme-option {
color: var(--color-zag-light);
}
.theme-option:hover {
background-color: rgba(128, 128, 128, 0.1);
}
.theme-option-icon {
margin-right: 12px;
color: currentColor;
}
[x-cloak] {
display: none !important;
}
</style>
<script>
// Enhanced theme toggle functionality
document.addEventListener('DOMContentLoaded', () => {
document
.getElementById("themeToggle")
?.addEventListener("click", handleToggleClick);
const lightThemeBtn = document.getElementById('lightTheme');
const darkThemeBtn = document.getElementById('darkTheme');
const systemThemeBtn = document.getElementById('systemTheme');
const autoThemeBtn = document.getElementById('autoTheme');
// Apply light theme
lightThemeBtn?.addEventListener('click', () => {
document.documentElement.classList.remove('dark');
localStorage.setItem('theme', 'light');
localStorage.setItem('theme-preference-explicit', 'true');
localStorage.setItem('theme-mode', 'manual');
window.dispatchEvent(new CustomEvent('theme-changed', {
detail: { theme: 'light', mode: 'manual' }
}));
});
// Apply dark theme
darkThemeBtn?.addEventListener('click', () => {
document.documentElement.classList.add('dark');
localStorage.setItem('theme', 'dark');
localStorage.setItem('theme-preference-explicit', 'true');
localStorage.setItem('theme-mode', 'manual');
window.dispatchEvent(new CustomEvent('theme-changed', {
detail: { theme: 'dark', mode: 'manual' }
}));
});
// Use system preference
systemThemeBtn?.addEventListener('click', () => {
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (isDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
localStorage.setItem('theme', isDark ? 'dark' : 'light');
localStorage.setItem('theme-preference-explicit', 'false');
localStorage.setItem('theme-mode', 'system');
window.dispatchEvent(new CustomEvent('theme-changed', {
detail: { theme: isDark ? 'dark' : 'light', mode: 'system' }
}));
});
// Use time-based theme
autoThemeBtn?.addEventListener('click', () => {
const hour = new Date().getHours();
const isDayTime = hour >= 7 && hour < 19; // 7 AM to 7 PM
if (!isDayTime) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
localStorage.setItem('theme', isDayTime ? 'light' : 'dark');
localStorage.setItem('theme-preference-explicit', 'false');
localStorage.setItem('theme-mode', 'auto');
window.dispatchEvent(new CustomEvent('theme-changed', {
detail: { theme: isDayTime ? 'light' : 'dark', mode: 'auto' }
}));
});
// Listen for system preference changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
if (localStorage.getItem('theme-mode') === 'system') {
const isDark = e.matches;
if (isDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
localStorage.setItem('theme', isDark ? 'dark' : 'light');
window.dispatchEvent(new CustomEvent('theme-changed', {
detail: { theme: isDark ? 'dark' : 'light', mode: 'system' }
}));
}
});
// Simple toggle functionality for the main button
const themeToggle = document.getElementById('themeToggle');
themeToggle?.addEventListener('click', () => {
// This is now handled by Alpine.js's x-data and @click
});
});
</script>

View File

@ -0,0 +1,94 @@
---
/**
* ThemeTransitionEffect component
*
* Provides a visual transition effect when switching between light and dark themes.
* Creates a radial gradient that expands from the theme toggle button.
*
* @component
*/
---
<div id="themeTransitionOverlay" class="theme-transition-overlay" aria-hidden="true"></div>
<style>
.theme-transition-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 9999;
opacity: 0;
transition: opacity 0.5s ease;
}
.theme-transition-overlay.light-to-dark {
background: radial-gradient(circle at var(--x) var(--y), rgba(40, 40, 40, 0.8) 0%, rgba(40, 40, 40, 0) 50%);
}
.theme-transition-overlay.dark-to-light {
background: radial-gradient(circle at var(--x) var(--y), rgba(235, 219, 178, 0.8) 0%, rgba(235, 219, 178, 0) 50%);
}
.theme-transition-overlay.active {
opacity: 1;
}
</style>
<script>
document.addEventListener('DOMContentLoaded', () => {
const overlay = document.getElementById('themeTransitionOverlay');
if (!overlay) return;
// Track current theme
let currentTheme = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
// Listen for theme changes
window.addEventListener('theme-changed', (e: Event) => {
const customEvent = e as CustomEvent<{theme: string}>;
const newTheme = customEvent.detail.theme;
if (newTheme === currentTheme) return;
// Get toggle button position for centered effect
const toggleBtn = document.getElementById('themeToggle');
let x = '50%';
let y = '50%';
if (toggleBtn) {
const rect = toggleBtn.getBoundingClientRect();
x = `${rect.left + rect.width / 2}px`;
y = `${rect.top + rect.height / 2}px`;
}
// Set the radial gradient position
overlay.style.setProperty('--x', x);
overlay.style.setProperty('--y', y);
// Add the appropriate class
if (newTheme === 'dark') {
overlay.classList.add('light-to-dark');
overlay.classList.remove('dark-to-light');
} else {
overlay.classList.add('dark-to-light');
overlay.classList.remove('light-to-dark');
}
// Trigger the animation
overlay.classList.add('active');
// Remove the animation after it completes
setTimeout(() => {
overlay.classList.remove('active');
overlay.classList.remove('light-to-dark');
overlay.classList.remove('dark-to-light');
}, 500);
// Update current theme
currentTheme = newTheme;
});
});
</script>

View File

@ -12,7 +12,7 @@ const { url, external, class: className } = Astro.props;
<a
href={url}
class:list={[
"zag-offset underline font-medium flex items-center focus:outline-2 focus:outline-offset-2 focus:outline-zag-dark dark:focus:outline-zag-light",
"zag-link zag-interactive font-medium flex items-center",
className,
]}
target={external ? "_blank" : "_self"}

View File

@ -0,0 +1,116 @@
---
interface Props {
animation: 'fade' | 'scale' | 'slide-up' | 'slide-down' | 'slide-left' | 'slide-right' | 'pulse';
duration?: number; // in milliseconds
delay?: number; // in milliseconds
easing?: string; // CSS easing function
class?: string;
tag?: string; // HTML tag to use
}
const {
animation,
duration = 300,
delay = 0,
easing = 'ease',
class: className = '',
tag: Tag = 'div'
} = Astro.props;
const animationClasses = {
'fade': 'animate-fade',
'scale': 'animate-scale',
'slide-up': 'animate-slide-up',
'slide-down': 'animate-slide-down',
'slide-left': 'animate-slide-left',
'slide-right': 'animate-slide-right',
'pulse': 'animate-pulse'
};
const animationClass = animationClasses[animation] || '';
---
<Tag class:list={[animationClass, className]}>
<style define:vars={{
animationDuration: `${duration}ms`,
animationDelay: `${delay}ms`,
animationEasing: easing
}}>
.animate-fade {
animation: fade var(--animationDuration) var(--animationEasing) var(--animationDelay) both;
}
.animate-scale {
animation: scale var(--animationDuration) var(--animationEasing) var(--animationDelay) both;
}
.animate-slide-up {
animation: slideUp var(--animationDuration) var(--animationEasing) var(--animationDelay) both;
}
.animate-slide-down {
animation: slideDown var(--animationDuration) var(--animationEasing) var(--animationDelay) both;
}
.animate-slide-left {
animation: slideLeft var(--animationDuration) var(--animationEasing) var(--animationDelay) both;
}
.animate-slide-right {
animation: slideRight var(--animationDuration) var(--animationEasing) var(--animationDelay) both;
}
.animate-pulse {
animation: pulse var(--animationDuration) var(--animationEasing) var(--animationDelay) infinite;
}
</style>
<slot />
</Tag>
<style>
@keyframes fade {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes scale {
from { transform: scale(0.95); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
@keyframes slideUp {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes slideDown {
from { transform: translateY(-20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes slideLeft {
from { transform: translateX(20px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slideRight {
from { transform: translateX(-20px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes pulse {
0% { transform: scale(1); }
50% { transform: scale(1.05); }
100% { transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.animate-fade, .animate-scale, .animate-slide-up,
.animate-slide-down, .animate-slide-left,
.animate-slide-right, .animate-pulse {
animation: none !important;
transition: none !important;
}
}
</style>

View File

@ -0,0 +1,183 @@
---
interface Props {
type?: string;
name: string;
label: string;
placeholder?: string;
required?: boolean;
pattern?: string;
minlength?: number;
maxlength?: number;
min?: number;
max?: number;
value?: string | number;
helperText?: string;
errorMessage?: string;
class?: string;
}
const {
type = 'text',
name,
label,
placeholder = '',
required = false,
pattern,
minlength,
maxlength,
min,
max,
value = '',
helperText = '',
errorMessage = '',
class: className = '',
} = Astro.props;
const id = `input-${name}`;
---
<div class:list={['form-field', className]}>
<label
for={id}
class="block text-sm font-medium mb-1 transition-all duration-200 form-label"
>
{label}{required && <span class="text-zag-button-red ml-1">*</span>}
</label>
<div class="relative">
<input
id={id}
type={type as any}
name={name}
placeholder={placeholder}
required={required}
pattern={pattern}
minlength={minlength}
maxlength={maxlength}
min={min}
max={max}
value={value}
class="form-input w-full px-3 py-2 border-2 border-solid rounded-lg focus:outline-none focus:ring-0 transition-all duration-200 zag-bg zag-text"
/>
<div class="validation-icon absolute right-3 top-1/2 transform -translate-y-1/2 opacity-0">
<!-- Valid icon -->
<svg
class="valid-icon h-5 w-5 text-green-500 hidden"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
</svg>
<!-- Invalid icon -->
<svg
class="invalid-icon h-5 w-5 text-red-500 hidden"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
</svg>
</div>
</div>
<!-- Helper text -->
<p class="helper-text mt-1 text-xs text-zag-text-muted">
{helperText}
</p>
<!-- Error message -->
<p class="error-text mt-1 text-xs text-zag-button-red hidden">
{errorMessage || 'Please enter a valid value'}
</p>
</div>
<style>
.form-field {
margin-bottom: 1.5rem;
}
.form-input:focus {
border-color: var(--color-zag-accent-dark);
}
.form-input:focus + .validation-icon {
opacity: 0;
}
.form-input:focus ~ .form-label {
color: var(--color-zag-accent-dark);
}
.form-input:valid:not(:focus):not(:placeholder-shown) {
border-color: #10b981; /* green-500 */
}
.form-input:invalid:not(:focus):not(:placeholder-shown) {
border-color: var(--color-zag-button-red);
}
.form-input:valid:not(:focus):not(:placeholder-shown) + .validation-icon .valid-icon {
display: block;
}
.form-input:invalid:not(:focus):not(:placeholder-shown) + .validation-icon .invalid-icon {
display: block;
}
.form-input:valid:not(:focus):not(:placeholder-shown) + .validation-icon,
.form-input:invalid:not(:focus):not(:placeholder-shown) + .validation-icon {
opacity: 1;
}
.form-input:invalid:not(:focus):not(:placeholder-shown) ~ .helper-text {
display: none;
}
.form-input:invalid:not(:focus):not(:placeholder-shown) ~ .error-text {
display: block;
}
/* Animation for validation icons */
.validation-icon svg {
transform-origin: center;
animation: pop 0.3s ease;
}
@keyframes pop {
0% { transform: scale(0.8) translateY(-50%); opacity: 0; }
100% { transform: scale(1) translateY(-50%); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.validation-icon svg {
animation: none;
}
}
</style>
<script>
// Add client-side validation
document.addEventListener('DOMContentLoaded', () => {
const formInputs = document.querySelectorAll('.form-input');
formInputs.forEach(input => {
const formField = input.closest('.form-field');
const label = formField?.querySelector('label');
input.addEventListener('focus', () => {
if (label) {
label.classList.add('text-zag-accent-dark');
}
});
input.addEventListener('blur', () => {
if (label) {
label.classList.remove('text-zag-accent-dark');
}
});
});
});
</script>

View File

@ -0,0 +1,126 @@
---
interface Props {
size?: 'small' | 'medium' | 'large';
type?: 'spinner' | 'dots' | 'pulse';
color?: string;
class?: string;
}
const {
size = 'medium',
type = 'spinner',
color = 'currentColor',
class: className = '',
} = Astro.props;
const sizeMap = {
small: 'w-4 h-4',
medium: 'w-8 h-8',
large: 'w-12 h-12',
};
const sizeClass = sizeMap[size] || sizeMap.medium;
---
{type === 'spinner' && (
<div class:list={['loading-spinner', sizeClass, className]} style={{ '--spinner-color': color }}>
<div class="spinner-ring"></div>
<div class="spinner-ring"></div>
<div class="spinner-ring"></div>
</div>
)}
{type === 'dots' && (
<div class:list={['loading-dots', sizeClass, className]} style={{ '--dots-color': color }}>
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
</div>
)}
{type === 'pulse' && (
<div class:list={['loading-pulse', sizeClass, className]} style={{ '--pulse-color': color }}>
</div>
)}
<style>
/* Spinner animation */
.loading-spinner {
position: relative;
display: inline-block;
}
.spinner-ring {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: 2px solid transparent;
border-top-color: var(--spinner-color, currentColor);
border-radius: 50%;
animation: spin 1s linear infinite;
}
.spinner-ring:nth-child(2) {
animation-delay: -0.3s;
}
.spinner-ring:nth-child(3) {
animation-delay: -0.6s;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Dots animation */
.loading-dots {
display: flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
}
.loading-dots .dot {
width: 25%;
height: 25%;
background-color: var(--dots-color, currentColor);
border-radius: 50%;
animation: dotBounce 1.4s infinite ease-in-out both;
}
.loading-dots .dot:nth-child(1) {
animation-delay: -0.32s;
}
.loading-dots .dot:nth-child(2) {
animation-delay: -0.16s;
}
@keyframes dotBounce {
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1); }
}
/* Pulse animation */
.loading-pulse {
background-color: var(--pulse-color, currentColor);
border-radius: 50%;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0% { transform: scale(0.8); opacity: 0.5; }
50% { transform: scale(1); opacity: 1; }
100% { transform: scale(0.8); opacity: 0.5; }
}
/* Respect reduced motion preferences */
@media (prefers-reduced-motion: reduce) {
.spinner-ring, .loading-dots .dot, .loading-pulse {
animation: none;
}
}
</style>

View File

@ -1,4 +1,6 @@
---
import LoadingIndicator from "./LoadingIndicator.astro";
interface Props {
message?: string;
subMessage?: string;
@ -18,14 +20,16 @@ const {
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>
<!-- Enhanced loading indicator -->
<div class="mb-6 flex justify-center">
<LoadingIndicator type="dots" size="large" color="var(--color-zag-accent-dark)" />
</div>
<!-- Messages -->
<p class="text-xl font-semibold mb-2 zag-text">{message}</p>
<p class="text-base zag-text-muted">{subMessage}</p>
<!-- Messages with animation -->
<div class="messages-container">
<p class="text-xl font-semibold mb-2 zag-text message-animate">{message}</p>
<p class="text-base zag-text-muted message-animate-delay">{subMessage}</p>
</div>
</div>
</div>
@ -41,26 +45,23 @@ const {
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;
/* Message animations */
.message-animate {
animation: fadeSlideUp 0.6s ease-out both;
}
:global(.dark) .spinner {
border-color: var(--color-zag-light-muted);
border-top-color: var(--color-zag-accent-dark);
.message-animate-delay {
animation: fadeSlideUp 0.6s ease-out 0.2s both;
}
@keyframes spin {
0% {
transform: rotate(0deg);
@keyframes fadeSlideUp {
from {
opacity: 0;
transform: translateY(10px);
}
100% {
transform: rotate(360deg);
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@ -0,0 +1,77 @@
---
import { Image } from 'astro:assets';
import type { ImageMetadata } from 'astro';
interface Props {
src: ImageMetadata | string;
alt: string;
width?: number;
height?: number;
class?: string;
sizes?: string;
loading?: 'eager' | 'lazy';
decoding?: 'sync' | 'async' | 'auto';
format?: 'webp' | 'avif' | 'png' | 'jpeg' | 'jpg';
quality?: number;
}
const {
src,
alt,
width,
height,
class: className = '',
sizes = '(min-width: 1024px) 1024px, 100vw',
loading = 'lazy',
decoding = 'async',
format = 'webp',
quality = 80
} = Astro.props;
// Determine if this is likely an above-the-fold image based on props
const isAboveFold = loading === 'eager';
---
{typeof src === 'string' ? (
<img
src={src}
alt={alt}
width={width}
height={height}
class={className}
loading={loading}
decoding={decoding}
/>
) : (
<Image
src={src}
alt={alt}
width={width}
height={height}
class={className}
sizes={sizes}
loading={loading}
decoding={decoding}
format={format}
quality={quality}
/>
)}
<style>
/* Prevent layout shifts by maintaining aspect ratio */
img {
display: block;
max-width: 100%;
height: auto;
}
/* Add subtle loading animation for lazy-loaded images */
img:not([loading="eager"]) {
animation: fadeIn 0.5s ease-in-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
</style>

View File

@ -0,0 +1,248 @@
---
import { Image } from 'astro:assets';
import type { ImageMetadata } from 'astro';
/**
* ResponsiveImage component with advanced features for optimal performance
* @component
*/
interface Props {
/**
* The image source (either an imported image or a URL)
*/
src: ImageMetadata | string;
/**
* Alternative text for the image
*/
alt: string;
/**
* Base width of the image
*/
width?: number;
/**
* Base height of the image
*/
height?: number;
/**
* CSS class to apply to the image
*/
class?: string;
/**
* Sizes attribute for responsive images
* @default "(min-width: 1280px) 1280px, (min-width: 1024px) 1024px, (min-width: 768px) 768px, 100vw"
*/
sizes?: string;
/**
* Loading strategy
* @default "lazy"
*/
loading?: 'eager' | 'lazy';
/**
* Decoding strategy
* @default "async"
*/
decoding?: 'sync' | 'async' | 'auto';
/**
* Image format
* @default "auto"
*/
format?: 'webp' | 'avif' | 'png' | 'jpeg' | 'jpg' | 'auto';
/**
* Image quality (1-100)
* @default 80
*/
quality?: number;
/**
* Whether to add a blur-up effect
* @default false
*/
blurUp?: boolean;
/**
* Whether this is a priority image (above the fold)
* @default false
*/
priority?: boolean;
/**
* Breakpoints for responsive images
* @default [640, 768, 1024, 1280]
*/
breakpoints?: number[];
/**
* Whether to use art direction (different images for different breakpoints)
* @default false
*/
artDirected?: boolean;
/**
* Mobile image source (for art direction)
*/
mobileSrc?: ImageMetadata | string;
/**
* Tablet image source (for art direction)
*/
tabletSrc?: ImageMetadata | string;
/**
* Desktop image source (for art direction)
*/
desktopSrc?: ImageMetadata | string;
}
const {
src,
alt,
width,
height,
class: className = '',
sizes = '(min-width: 1280px) 1280px, (min-width: 1024px) 1024px, (min-width: 768px) 768px, 100vw',
loading: propLoading,
decoding = 'async',
format = 'auto',
quality = 80,
blurUp = false,
priority = false,
breakpoints = [640, 768, 1024, 1280],
artDirected = false,
mobileSrc,
tabletSrc,
desktopSrc
} = Astro.props;
// Determine loading strategy based on priority
const loading = priority ? 'eager' : propLoading || 'lazy';
// Generate a unique ID for this image instance
const imageId = `img-${Math.random().toString(36).substring(2, 11)}`;
// Determine if we're using a string URL or an imported image
const isStringSource = typeof src === 'string';
// Placeholder for blur-up effect (simplified version)
const placeholderStyle = blurUp ? 'filter: blur(20px); transition: filter 0.5s ease-out;' : '';
---
{artDirected ? (
<picture>
{/* Mobile image */}
<source
media="(max-width: 640px)"
srcset={typeof mobileSrc === 'string' ? mobileSrc : typeof src === 'string' ? src : ''}
/>
{/* Tablet image */}
<source
media="(min-width: 641px) and (max-width: 1023px)"
srcset={typeof tabletSrc === 'string' ? tabletSrc : typeof src === 'string' ? src : ''}
/>
{/* Desktop image */}
<source
media="(min-width: 1024px)"
srcset={typeof desktopSrc === 'string' ? desktopSrc : typeof src === 'string' ? src : ''}
/>
{/* Fallback image */}
{isStringSource ? (
<img
src={src}
alt={alt}
width={width}
height={height}
class={className}
loading={loading}
decoding={decoding}
id={imageId}
style={placeholderStyle}
/>
) : (
<Image
src={src}
alt={alt}
width={width}
height={height}
class={className}
sizes={sizes}
loading={loading}
decoding={decoding}
quality={quality}
id={imageId}
/>
)}
</picture>
) : (
/* Standard responsive image */
isStringSource ? (
<img
src={src}
alt={alt}
width={width}
height={height}
class={className}
loading={loading}
decoding={decoding}
id={imageId}
style={placeholderStyle}
/>
) : (
<Image
src={src}
alt={alt}
width={width}
height={height}
class={className}
sizes={sizes}
loading={loading}
decoding={decoding}
format={format === 'auto' ? undefined : format}
quality={quality}
id={imageId}
/>
)
)}
{blurUp && (
<script define:vars={{ imageId }}>
// Simple blur-up effect
document.addEventListener('DOMContentLoaded', () => {
const img = document.getElementById(imageId);
if (img) {
img.onload = () => {
img.style.filter = 'blur(0)';
};
}
});
</script>
)}
<style>
/* Prevent layout shifts by maintaining aspect ratio */
img {
display: block;
max-width: 100%;
height: auto;
}
/* Add subtle loading animation for lazy-loaded images */
img:not([loading="eager"]) {
animation: fadeIn 0.5s ease-in-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
</style>

View File

@ -0,0 +1,135 @@
---
interface Props {
animation?: 'fade-up' | 'fade-down' | 'fade-left' | 'fade-right' | 'zoom-in' | 'zoom-out';
duration?: number; // in milliseconds
delay?: number; // in milliseconds
threshold?: number; // 0-1, percentage of element visible to trigger
rootMargin?: string; // CSS margin value
once?: boolean; // animate only once or every time element enters viewport
class?: string;
}
const {
animation = 'fade-up',
duration = 600,
delay = 0,
threshold = 0.1,
rootMargin = '0px',
once = true,
class: className = '',
} = Astro.props;
const id = `scroll-reveal-${Math.random().toString(36).substring(2, 9)}`;
---
<div
id={id}
class:list={['scroll-reveal', className]}
data-animation={animation}
data-duration={duration}
data-delay={delay}
data-threshold={threshold}
data-root-margin={rootMargin}
data-once={once}
>
<slot />
</div>
<style>
.scroll-reveal {
opacity: 0;
will-change: transform, opacity;
}
.scroll-reveal[data-animation="fade-up"] {
transform: translateY(30px);
}
.scroll-reveal[data-animation="fade-down"] {
transform: translateY(-30px);
}
.scroll-reveal[data-animation="fade-left"] {
transform: translateX(30px);
}
.scroll-reveal[data-animation="fade-right"] {
transform: translateX(-30px);
}
.scroll-reveal[data-animation="zoom-in"] {
transform: scale(0.9);
}
.scroll-reveal[data-animation="zoom-out"] {
transform: scale(1.1);
}
.scroll-reveal.revealed {
opacity: 1;
transform: translate(0) scale(1);
}
@media (prefers-reduced-motion: reduce) {
.scroll-reveal {
transition: none !important;
opacity: 1 !important;
transform: none !important;
}
}
</style>
<script>
// Initialize scroll reveal animations
document.addEventListener('DOMContentLoaded', () => {
const scrollRevealElements = document.querySelectorAll('.scroll-reveal');
if (!scrollRevealElements.length) return;
// Check if IntersectionObserver is supported
if ('IntersectionObserver' in window) {
scrollRevealElements.forEach(element => {
// Get animation parameters from data attributes
const duration = parseInt(element.getAttribute('data-duration') || '600', 10);
const delay = parseInt(element.getAttribute('data-delay') || '0', 10);
const threshold = parseFloat(element.getAttribute('data-threshold') || '0.1');
const rootMargin = element.getAttribute('data-root-margin') || '0px';
const once = element.getAttribute('data-once') === 'true';
// Set transition based on parameters
(element as HTMLElement).style.transition = `opacity ${duration}ms ease ${delay}ms, transform ${duration}ms ease ${delay}ms`;
// Create observer for this element
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('revealed');
// Unobserve if once is true
if (once) {
observer.unobserve(entry.target);
}
} else if (!once) {
// Remove class if element leaves viewport and once is false
entry.target.classList.remove('revealed');
}
});
},
{
threshold,
rootMargin
}
);
// Start observing
observer.observe(element);
});
} else {
// Fallback for browsers that don't support IntersectionObserver
scrollRevealElements.forEach(element => {
element.classList.add('revealed');
});
}
});
</script>

View File

@ -13,6 +13,15 @@ const {
---
<div class={`search-container ${className}`}>
<style>
.search-input {
box-shadow: 2px 2px 0 var(--color-zag-dark);
}
:where(.dark, .dark *) .search-input {
box-shadow: 2px 2px 0 var(--color-zag-light);
}
</style>
<!-- Hidden live region for screen readers -->
<div
id="search-status"
@ -26,7 +35,7 @@ const {
<div class="relative">
<label for="app-search" class="sr-only">{ariaLabel}</label>
<!-- Search icon -->
<div class="absolute inset-y-0 left-0 flex items-center pl-2 pointer-events-none">
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none z-10">
<svg class="w-4 h-4 zag-text" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
@ -40,7 +49,7 @@ const {
aria-label={ariaLabel}
aria-describedby="search-status search-hint"
aria-controls="app-list"
class="w-full pl-8 pr-8 py-1 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-current zag-text zag-bg"
class="search-input w-full pl-9 pr-8 py-1.5 text-sm border-2 border-solid zag-border-b rounded-lg focus:outline-none focus:ring-2 focus:ring-current zag-text zag-bg zag-transition"
/>
<button
x-show="searchQuery"
@ -55,7 +64,7 @@ const {
</div>
<!-- Tooltip area - shows either help text or status text -->
<div class="mt-1 h-5 text-center"> <!-- Fixed height to prevent layout shift -->
<div class="mt-2 h-5 text-center"> <!-- Fixed height to prevent layout shift -->
<!-- Keyboard shortcut hint - shown when search is empty -->
<div
id="search-hint"

View File

@ -1,19 +1,196 @@
---
/**
* ServiceCard component displays a service with an icon and name
* @component
* @example
* ```astro
* <ServiceCard
* name="Gitea"
* href="https://code.justin.deal"
* img="https://cdn.jsdelivr.net/gh/selfhst/icons/svg/gitea.svg"
* alt="Gitea"
* />
* ```
*/
interface Props {
/**
* The name of the service to display
*/
name: string;
/**
* The URL to link to when the service card is clicked
*/
href: string;
/**
* The URL of the service icon
*/
img: string;
/**
* Alternative text for the service icon
*/
alt: string;
}
const { name, href, img, alt } = Astro.props;
---
<a
href={href}
class="flex flex-col items-center transition-transform transform hover:scale-105 hover:opacity-90"
class="service-card zag-interactive flex items-center transition-all duration-300"
target="_blank"
rel="noopener noreferrer"
>
<img
src={img}
alt={alt}
class="w-16 h-16"
loading="lazy"
decoding="async"
/>
<p class="mt-2 text-center">{name}</p>
<div class="service-icon-container flex-shrink-0">
<img
src={img}
alt={alt}
class="service-icon w-16 h-16 transition-all duration-300"
loading="lazy"
decoding="async"
/>
</div>
<p class="service-name mt-2 text-center transition-all duration-300">{name}</p>
</a>
<style>
/* Default (grid) view */
.service-card {
flex-direction: column;
justify-content: center;
}
/* List view adjustments applied via JS */
:global(.view-mode-list) .service-card {
flex-direction: row;
justify-content: flex-start;
padding: 0.5rem;
border-radius: 0.375rem;
}
:global(.view-mode-list) .service-name {
margin-top: 0;
margin-left: 1rem;
text-align: left;
}
/* Display mode styles */
/* Default display mode (both) */
.service-icon-container, .service-name {
display: block;
}
/* Image only mode */
:global(.display-image-only) .service-name {
display: none;
}
:global(.display-image-only) .service-icon-container {
display: flex;
justify-content: center;
align-items: center;
}
/* Name only mode */
:global(.display-name-only) .service-icon-container {
display: none;
}
:global(.display-name-only) .service-name {
display: block;
margin-top: 0;
font-size: 1.1rem;
}
/* Adjust list view for different display modes */
:global(.view-mode-list.display-name-only) .service-card {
padding: 0.75rem 1rem;
}
:global(.view-mode-list.display-image-only) .service-card {
justify-content: center;
padding: 0.5rem;
}
/* Icon size adjustments with CSS variables for fine-grained control */
:global(#app-list) {
--icon-scale: 2; /* Default medium size */
--icon-base-size: 1rem;
}
.service-icon {
width: calc(var(--icon-base-size) * var(--icon-scale) * 2);
height: calc(var(--icon-base-size) * var(--icon-scale) * 2);
}
/* Fallback discrete sizes for browsers that don't support calc */
:global(.icon-size-small) .service-icon {
width: 2rem;
height: 2rem;
}
:global(.icon-size-medium) .service-icon {
width: 4rem;
height: 4rem;
}
:global(.icon-size-large) .service-icon {
width: 6rem;
height: 6rem;
}
/* Enhanced hover effects */
.service-card {
transition: all 0.3s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
border: 2px solid transparent;
background-color: var(--color-zag-bg);
border-radius: 0.5rem;
overflow: hidden;
position: relative;
}
.service-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(135deg, transparent 0%, var(--color-zag-accent) 300%);
opacity: 0;
transition: opacity 0.3s ease;
z-index: -1;
}
.service-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
border-color: var(--color-zag-accent);
background-color: var(--color-zag-bg-hover);
z-index: 10;
}
.service-card:hover::before {
opacity: 0.1;
}
.service-card:hover .service-icon {
transform: scale(1.1);
}
:global(.view-mode-list) .service-card:hover {
transform: translateX(4px);
}
/* Dark mode adjustments */
:global(.dark) .service-card {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
:global(.dark) .service-card:hover {
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.4);
}
</style>

View File

@ -0,0 +1,166 @@
---
interface Props {
showSizeSelector?: boolean;
showViewSelector?: boolean;
className?: string;
}
const {
showSizeSelector = true,
showViewSelector = true,
className = "",
} = Astro.props;
---
<div class={`flex items-center gap-4 ${className}`}>
{showSizeSelector && (
<div class="flex items-center gap-2">
<span class="text-sm zag-text-muted hidden sm:inline">Size:</span>
<div class="size-selector flex items-center gap-1 border-2 border-solid zag-border-b rounded-lg p-1 zag-bg zag-transition">
<button
@click="setIconSize('small')"
:class="iconSize === 'small' ? 'active-size' : 'inactive-size'"
class="p-1.5 transition-all rounded-md focus:outline-none focus:ring-2 focus:ring-current"
aria-label="Small icons"
title="Small icons (Alt+1)"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
</svg>
</button>
<button
@click="setIconSize('medium')"
:class="iconSize === 'medium' ? 'active-size' : 'inactive-size'"
class="p-1.5 transition-all rounded-md focus:outline-none focus:ring-2 focus:ring-current"
aria-label="Medium icons"
title="Medium icons (Alt+2)"
>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
</svg>
</button>
<button
@click="setIconSize('large')"
:class="iconSize === 'large' ? 'active-size' : 'inactive-size'"
class="p-1.5 transition-all rounded-md focus:outline-none focus:ring-2 focus:ring-current"
aria-label="Large icons"
title="Large icons (Alt+3)"
>
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
</svg>
</button>
</div>
</div>
)}
{/* Display options selector */}
<div class="flex items-center gap-2">
<span class="text-sm zag-text-muted hidden sm:inline">Display:</span>
<div class="display-selector flex items-center gap-1 border-2 border-solid zag-border-b rounded-lg p-1 zag-bg zag-transition">
<button
@click="setDisplayMode('both')"
:class="displayMode === 'both' ? 'active-display' : 'inactive-display'"
class="p-1.5 transition-all rounded-md focus:outline-none focus:ring-2 focus:ring-current"
aria-label="Show both image and name"
title="Show both image and name (Alt+B)"
>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="12" rx="2" ry="2"></rect>
<line x1="3" y1="19" x2="21" y2="19"></line>
<line x1="3" y1="23" x2="21" y2="23"></line>
</svg>
</button>
<button
@click="setDisplayMode('image')"
:class="displayMode === 'image' ? 'active-display' : 'inactive-display'"
class="p-1.5 transition-all rounded-md focus:outline-none focus:ring-2 focus:ring-current"
aria-label="Show image only"
title="Show image only (Alt+I)"
>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="12" cy="12" r="3"></circle>
</svg>
</button>
<button
@click="setDisplayMode('name')"
:class="displayMode === 'name' ? 'active-display' : 'inactive-display'"
class="p-1.5 transition-all rounded-md focus:outline-none focus:ring-2 focus:ring-current"
aria-label="Show name only"
title="Show name only (Alt+N)"
>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="3" y1="12" x2="21" y2="12"></line>
<line x1="3" y1="6" x2="21" y2="6"></line>
<line x1="3" y1="18" x2="21" y2="18"></line>
</svg>
</button>
</div>
</div>
{showViewSelector && (
<div class="flex items-center gap-2">
<span class="text-sm zag-text-muted hidden sm:inline">View:</span>
<div class="view-selector border-2 border-solid zag-border-b rounded-lg p-1 zag-bg zag-transition">
<button
@click="toggleViewMode"
:class="viewMode === 'grid' ? 'active-view' : 'inactive-view'"
class="p-1.5 transition-all rounded-md focus:outline-none focus:ring-2 focus:ring-current"
aria-label="Toggle view mode"
:title="viewMode === 'grid' ? 'Switch to list view (Alt+G)' : 'Switch to grid view (Alt+G)'"
>
<svg x-show="viewMode === 'grid'" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
<svg x-show="viewMode === 'list'" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="8" y1="6" x2="21" y2="6"></line>
<line x1="8" y1="12" x2="21" y2="12"></line>
<line x1="8" y1="18" x2="21" y2="18"></line>
<line x1="3" y1="6" x2="3.01" y2="6"></line>
<line x1="3" y1="12" x2="3.01" y2="12"></line>
<line x1="3" y1="18" x2="3.01" y2="18"></line>
</svg>
</button>
</div>
</div>
)}
</div>
<style>
.size-selector, .view-selector, .display-selector {
box-shadow: 2px 2px 0 var(--color-zag-dark);
:where(.dark, .dark *) & {
box-shadow: 2px 2px 0 var(--color-zag-light);
}
}
.active-size, .active-view, .active-display {
color: var(--color-zag-dark);
background-color: var(--color-zag-light);
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
:where(.dark, .dark *) & {
color: var(--color-zag-dark);
}
}
.inactive-size, .inactive-view, .inactive-display {
color: var(--color-zag-dark-muted);
:where(.dark, .dark *) & {
color: var(--color-zag-light-muted);
}
}
.inactive-size:hover, .inactive-view:hover, .inactive-display:hover {
background-color: var(--color-zag-light-muted);
color: var(--color-zag-dark);
:where(.dark, .dark *) & {
background-color: var(--color-zag-dark-muted);
color: var(--color-zag-light);
}
}
</style>

View File

@ -1,176 +0,0 @@
/**
* Client-side search functionality for filtering content across the site
*/
/**
* Initialize search functionality for any content type
* @param {string} contentSelector - CSS selector for the items to filter
* @param {Object} options - Configuration options
* @returns {Object} Alpine.js data object with search functionality
*/
function initializeSearch(contentSelector = '.searchable-item', options = {}) {
const defaults = {
nameAttribute: 'data-name',
tagsAttribute: 'data-tags',
categoryAttribute: 'data-category',
additionalAttributes: [],
noResultsMessage: 'No results found',
allItemsMessage: 'Showing all items',
resultCountMessage: (count) => `Found ${count} items`,
itemLabel: 'items'
};
const config = { ...defaults, ...options };
return {
searchQuery: '',
hasResults: true,
visibleCount: 0,
init() {
// Initialize the visible count
this.visibleCount = document.querySelectorAll(contentSelector).length;
this.setupWatchers();
this.setupKeyboardShortcuts();
},
setupWatchers() {
this.$watch('searchQuery', (query) => {
this.filterContent(query);
});
},
setupKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
// '/' key focuses the search input
if (e.key === '/' && document.activeElement.id !== 'app-search') {
e.preventDefault();
document.getElementById('app-search').focus();
}
// Escape key clears the search
if (e.key === 'Escape' && this.searchQuery !== '') {
this.searchQuery = '';
document.getElementById('app-search').focus();
}
});
},
filterContent(query) {
query = query.toLowerCase();
let anyResults = false;
let visibleCount = 0;
// Process all content items
document.querySelectorAll(contentSelector).forEach((item) => {
// Get searchable attributes
const name = (item.getAttribute(config.nameAttribute) || '').toLowerCase();
const tags = (item.getAttribute(config.tagsAttribute) || '').toLowerCase();
const category = (item.getAttribute(config.categoryAttribute) || '').toLowerCase();
// Check additional attributes if specified
const additionalMatches = config.additionalAttributes.some(attr => {
const value = (item.getAttribute(attr) || '').toLowerCase();
return value.includes(query);
});
const isMatch = query === '' ||
name.includes(query) ||
tags.includes(query) ||
category.includes(query) ||
additionalMatches;
if (isMatch) {
item.style.display = '';
anyResults = true;
visibleCount++;
} else {
item.style.display = 'none';
}
});
// Update parent containers if needed
this.updateContainerVisibility(query);
this.updateResultsStatus(query, anyResults, visibleCount);
},
updateContainerVisibility(query) {
// If there are container elements that should be hidden when empty
const containers = document.querySelectorAll('.content-container');
if (containers.length > 0) {
containers.forEach((container) => {
const hasVisibleItems = Array.from(
container.querySelectorAll(contentSelector)
).some((item) => item.style.display !== 'none');
if (query === '' || hasVisibleItems) {
container.style.display = '';
} else {
container.style.display = 'none';
}
});
}
},
updateResultsStatus(query, anyResults, count) {
// Update results status
this.hasResults = query === '' || anyResults;
this.visibleCount = count;
// Update screen reader status
const statusEl = document.getElementById('search-status');
if (statusEl) {
if (query === '') {
statusEl.textContent = config.allItemsMessage;
this.visibleCount = document.querySelectorAll(contentSelector).length;
} else if (this.hasResults) {
statusEl.textContent = config.resultCountMessage(count);
} else {
statusEl.textContent = config.noResultsMessage;
}
}
}
};
}
// Register Alpine.js data components
document.addEventListener('alpine:init', () => {
// Homelab search
window.Alpine.data('searchServices', () => {
return initializeSearch('.app-card', {
nameAttribute: 'data-app-name',
tagsAttribute: 'data-app-tags',
categoryAttribute: 'data-app-category',
noResultsMessage: 'No services found',
allItemsMessage: 'Showing all services',
resultCountMessage: (count) => `Found ${count} services`,
itemLabel: 'services'
});
});
// Blog search
window.Alpine.data('searchArticles', () => {
return initializeSearch('.article-item', {
nameAttribute: 'data-title',
tagsAttribute: 'data-tags',
additionalAttributes: ['data-description'],
noResultsMessage: 'No articles found',
allItemsMessage: 'Showing all articles',
resultCountMessage: (count) => `Found ${count} articles`,
itemLabel: 'articles'
});
});
// Projects search
window.Alpine.data('searchProjects', () => {
return initializeSearch('.project-item', {
nameAttribute: 'data-title',
tagsAttribute: 'data-tags',
additionalAttributes: ['data-description', 'data-github', 'data-live'],
noResultsMessage: 'No projects found',
allItemsMessage: 'Showing all projects',
resultCountMessage: (count) => `Found ${count} projects`,
itemLabel: 'projects'
});
});
});

View File

@ -1,132 +0,0 @@
/**
* Generic search utility for filtering content across the site
*/
/**
* Initialize search functionality for any content type
* @returns {Object} Alpine.js data object with search functionality
*/
export function initializeSearch(contentSelector = '.searchable-item', options = {}) {
const defaults = {
nameAttribute: 'data-name',
tagsAttribute: 'data-tags',
categoryAttribute: 'data-category',
additionalAttributes: [],
noResultsMessage: 'No results found',
allItemsMessage: 'Showing all items',
resultCountMessage: (count) => `Found ${count} items`,
itemLabel: 'items'
};
const config = { ...defaults, ...options };
return {
searchQuery: '',
hasResults: true,
visibleCount: 0,
init() {
// Initialize the visible count
this.visibleCount = document.querySelectorAll(contentSelector).length;
this.setupWatchers();
this.setupKeyboardShortcuts();
},
setupWatchers() {
this.$watch('searchQuery', (query) => {
this.filterContent(query);
});
},
setupKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
// '/' key focuses the search input
if (e.key === '/' && document.activeElement.id !== 'app-search') {
e.preventDefault();
document.getElementById('app-search').focus();
}
// Escape key clears the search
if (e.key === 'Escape' && this.searchQuery !== '') {
this.searchQuery = '';
document.getElementById('app-search').focus();
}
});
},
filterContent(query) {
query = query.toLowerCase();
let anyResults = false;
let visibleCount = 0;
// Process all content items
document.querySelectorAll(contentSelector).forEach((item) => {
// Get searchable attributes
const name = (item.getAttribute(config.nameAttribute) || '').toLowerCase();
const tags = (item.getAttribute(config.tagsAttribute) || '').toLowerCase();
const category = (item.getAttribute(config.categoryAttribute) || '').toLowerCase();
// Check additional attributes if specified
const additionalMatches = config.additionalAttributes.some(attr => {
const value = (item.getAttribute(attr) || '').toLowerCase();
return value.includes(query);
});
const isMatch = query === '' ||
name.includes(query) ||
tags.includes(query) ||
category.includes(query) ||
additionalMatches;
if (isMatch) {
item.style.display = '';
anyResults = true;
visibleCount++;
} else {
item.style.display = 'none';
}
});
// Update parent containers if needed
this.updateContainerVisibility(query);
this.updateResultsStatus(query, anyResults, visibleCount);
},
updateContainerVisibility(query) {
// If there are container elements that should be hidden when empty
const containers = document.querySelectorAll('.content-container');
if (containers.length > 0) {
containers.forEach((container) => {
const hasVisibleItems = Array.from(
container.querySelectorAll(contentSelector)
).some((item) => item.style.display !== 'none');
if (query === '' || hasVisibleItems) {
container.style.display = '';
} else {
container.style.display = 'none';
}
});
}
},
updateResultsStatus(query, anyResults, count) {
// Update results status
this.hasResults = query === '' || anyResults;
this.visibleCount = count;
// Update screen reader status
const statusEl = document.getElementById('search-status');
if (statusEl) {
if (query === '') {
statusEl.textContent = config.allItemsMessage;
this.visibleCount = document.querySelectorAll(contentSelector).length;
} else if (this.hasResults) {
statusEl.textContent = config.resultCountMessage(count);
} else {
statusEl.textContent = config.noResultsMessage;
}
}
}
};
}

View File

@ -1,23 +1,34 @@
---
import { GLOBAL } from "../../lib/variables";
import OptimizedImage from "../common/OptimizedImage.astro";
import AnimatedElement from "../common/AnimatedElement.astro";
import profileImage from "../../assets/images/pixel_avatar.png";
---
<div class="flex flex-col items-center sm:flex-row gap-8">
<img
src={GLOBAL.profileImage}
alt={GLOBAL.username}
class="rounded-full max-w-40 w-40 h-40 mb-4 z-0 grayscale"
/>
<AnimatedElement animation="scale" duration={600}>
<OptimizedImage
src={profileImage}
alt={GLOBAL.username}
width={160}
height={160}
class="rounded-full max-w-40 w-40 h-40 mb-4 z-0 grayscale hover:grayscale-0 transition-all duration-500"
/>
</AnimatedElement>
<div>
<h1
class="text-3xl sm:text-4xl font-display font-semibold opsz text-center sm:text-left"
>
{GLOBAL.username}
</h1>
<h2
class="text-center text-xl sm:text-2xl font-mono font-medium sm:text-left"
>
<p set:html={GLOBAL.shortDescription}/>
</h2>
<AnimatedElement animation="slide-up" duration={500} delay={200}>
<h1
class="text-3xl sm:text-4xl font-display font-semibold opsz text-center sm:text-left"
>
{GLOBAL.username}
</h1>
</AnimatedElement>
<AnimatedElement animation="slide-up" duration={500} delay={400}>
<h2
class="text-center text-xl sm:text-2xl font-mono font-medium sm:text-left"
>
<p set:html={GLOBAL.shortDescription}/>
</h2>
</AnimatedElement>
</div>
</div>

View File

@ -1,17 +1,39 @@
---
import { type Service } from "../../lib/types";
import ServiceCard from "../common/ServiceCard.astro";
import ScrollReveal from "../common/ScrollReveal.astro";
/**
* CategorySection component displays a collapsible section of services grouped by category
* @component
* @example
* ```astro
* <CategorySection
* category="Development"
* apps={[
* {
* name: "Gitea",
* link: "https://code.justin.deal",
* icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/gitea.svg",
* alt: "Gitea"
* }
* ]}
* />
* ```
*/
interface Props {
/**
* The category name to display as the section title
*/
category: string;
apps: Array<{
name: string;
link: string;
icon: string;
alt: string;
tags?: string[];
}>;
/**
* Array of service objects to display in this category
*/
apps: Service[];
}
const { category, apps } = Astro.props;
import ServiceCard from "../common/ServiceCard.astro";
// Pre-compute values during server-side rendering
const categoryId = `category-${category.toLowerCase().replace(/\s+/g, '-')}`;
@ -43,26 +65,33 @@ const categoryLower = category.toLowerCase();
x-transition
id={categoryId}
>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4 transition-all duration-300">
{apps.length > 0 ? (
apps.map(app => {
const appName = app.name.toLowerCase();
const appTags = app.tags ? app.tags.join(' ').toLowerCase() : '';
return (
<div
class="app-card transition-all duration-300"
data-app-name={appName}
data-app-tags={appTags}
data-app-category={categoryLower}
<ScrollReveal
animation="fade-up"
delay={100 * apps.indexOf(app)}
duration={500}
threshold={0.1}
>
<ServiceCard
name={app.name}
href={app.link}
img={app.icon}
alt={app.name}
/>
</div>
<div
class="app-card transition-all duration-300"
data-app-name={appName}
data-app-tags={appTags}
data-app-category={categoryLower}
>
<ServiceCard
name={app.name}
href={app.link}
img={app.icon}
alt={app.name}
/>
</div>
</ScrollReveal>
);
})
) : (

View File

@ -1,100 +0,0 @@
/**
* Initialize search functionality for the homelab page
* This function sets up the search filtering, keyboard shortcuts,
* and status updates for the search feature
*/
function initializeSearch() {
return {
searchQuery: '',
hasResults: true,
init() {
this.setupWatchers();
this.setupKeyboardShortcuts();
},
setupWatchers() {
this.$watch('searchQuery', (query) => {
this.filterServices(query);
});
},
setupKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
// '/' key focuses the search input
if (e.key === '/' && document.activeElement.id !== 'app-search') {
e.preventDefault();
document.getElementById('app-search').focus();
}
// Escape key clears the search
if (e.key === 'Escape' && this.searchQuery !== '') {
this.searchQuery = '';
document.getElementById('app-search').focus();
}
});
},
filterServices(query) {
query = query.toLowerCase();
let anyResults = false;
let visibleCount = 0;
// Process all service cards
document.querySelectorAll('.app-card').forEach((card) => {
const serviceName = card.getAttribute('data-app-name') || '';
const serviceTags = card.getAttribute('data-app-tags') || '';
const serviceCategory = card.getAttribute('data-app-category') || '';
const isMatch = query === '' ||
serviceName.includes(query) ||
serviceTags.includes(query) ||
serviceCategory.includes(query);
if (isMatch) {
card.style.display = '';
anyResults = true;
visibleCount++;
} else {
card.style.display = 'none';
}
});
this.updateCategoryVisibility(query);
this.updateResultsStatus(query, anyResults, visibleCount);
},
updateCategoryVisibility(query) {
document.querySelectorAll('.category-section').forEach((category) => {
const hasVisibleApps = Array.from(
category.querySelectorAll('.app-card')
).some((card) => card.style.display !== 'none');
if (query === '' || hasVisibleApps) {
category.style.display = '';
} else {
category.style.display = 'none';
}
});
},
updateResultsStatus(query, anyResults, visibleCount) {
// Update results status
this.hasResults = query === '' || anyResults;
// Update screen reader status
const statusEl = document.getElementById('search-status');
if (statusEl) {
if (query === '') {
statusEl.textContent = 'Showing all services';
} else if (this.hasResults) {
statusEl.textContent = 'Found ' + visibleCount + ' services matching ' + query;
} else {
statusEl.textContent = 'No services found matching ' + query;
}
}
}
};
}
export { initializeSearch };

13
src/env.d.ts vendored Normal file
View File

@ -0,0 +1,13 @@
/// <reference types="astro/client" />
/**
* Type definitions for environment variables
*/
interface ImportMetaEnv {
readonly PUBLIC_SITE_URL: string;
// Add other environment variables here as needed
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@ -3,6 +3,9 @@ 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 ThemeTransitionEffect from "../components/ThemeTransitionEffect.astro";
import ThemeBackground from "../components/ThemeBackground.astro";
import ThemeScheduler from "../components/ThemeScheduler.astro";
import "../styles/global.css";
---
@ -38,16 +41,90 @@ import "../styles/global.css";
document.documentElement.classList.add('theme-loaded');
})();
</script>
<!-- Service worker registration -->
<script is:inline>
// Register service worker for offline support and caching
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/service-worker.js')
.then(registration => {
console.log('SW registered: ', registration.scope);
})
.catch(error => {
console.log('SW registration failed: ', error);
});
});
}
</script>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="/pixel_avatar.png" />
<meta name="generator" content={Astro.generator} />
<link rel="preconnect" href="https://fonts.bunny.net" />
<!-- Favicons -->
<link rel="icon" type="image/svg+xml" href="/favicons/favicon.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/favicons/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicons/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicons/favicon-16x16.png" />
<link rel="manifest" href="/site.webmanifest" />
<!-- Theme colors -->
<meta name="theme-color" content="#282828" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#ebdbb2" media="(prefers-color-scheme: light)" />
<!-- Preconnect to external resources -->
<link rel="preconnect" href="https://fonts.bunny.net" crossorigin />
<link rel="dns-prefetch" href="https://fonts.bunny.net" />
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<link rel="dns-prefetch" href="https://cdn.jsdelivr.net" />
<link rel="preconnect" href="https://unpkg.com" crossorigin />
<link rel="dns-prefetch" href="https://unpkg.com" />
<!-- Preload critical fonts with font-display: swap -->
<link
rel="preload"
href="https://fonts.bunny.net/css?family=ibm-plex-mono:400,400i,500,500i,600,600i,700,700i&display=swap"
as="style"
/>
<link
href="https://fonts.bunny.net/css?family=ibm-plex-mono:400,400i,500,500i,600,600i,700,700i"
href="https://fonts.bunny.net/css?family=ibm-plex-mono:400,400i,500,500i,600,600i,700,700i&display=swap"
rel="stylesheet"
/>
<script src="https://unpkg.com/alpinejs" defer></script>
<!-- Preload critical font files -->
<link
rel="preload"
href="https://cdn.jsdelivr.net/fontsource/fonts/press-start-2p@latest/latin-400-normal.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<!-- Preload Alpine.js -->
<link rel="preload" href="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" as="script" />
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<!-- Font loading observer -->
<script is:inline>
if ("fonts" in document) {
Promise.all([
document.fonts.load("1em IBM Plex Mono"),
document.fonts.load("1em press-start-2p")
]).then(() => {
document.documentElement.classList.add("fonts-loaded");
});
} else {
document.documentElement.classList.add("fonts-loaded");
}
</script>
<!-- Time-based theme scheduler -->
<ThemeScheduler />
<!-- Theme transition script -->
<script src="/src/scripts/ThemeTransition.js"></script>
<SearchScript />
<slot name="head" />
</head>
@ -55,6 +132,10 @@ import "../styles/global.css";
<!-- Loading overlay for long loading times -->
<LoadingOverlay />
<!-- Theme-specific background patterns and transition effect -->
<ThemeBackground />
<ThemeTransitionEffect />
<Header />
<main>
<slot />

View File

@ -1,3 +1,6 @@
/**
* Represents the frontmatter for a project
*/
export type ProjectFrontmatter = {
/**
* The title of the project
@ -43,6 +46,9 @@ export type ProjectFrontmatter = {
filename: string;
};
/**
* Represents the frontmatter for an article
*/
export type ArticleFrontmatter = {
/**
* The title of the article
@ -50,7 +56,7 @@ export type ArticleFrontmatter = {
title: string;
/**
* THe summary description of the article
* The summary description of the article
*/
description: string;
@ -81,3 +87,40 @@ export type ArticleFrontmatter = {
*/
filename: string;
};
/**
* Represents a service in the homelab
*/
export type Service = {
/**
* The name of the service
*/
name: string;
/**
* The URL to the service
*/
link: string;
/**
* The URL to the service icon
*/
icon: string;
/**
* Alternative text for the service icon
*/
alt: string;
/**
* Tags associated with the service for filtering and categorization
*/
tags?: string[];
};
/**
* Represents a category of services in the homelab
*/
export type ServiceCategory = {
[category: string]: Service[];
};

View File

@ -1,5 +1,28 @@
// Set any item to undefined to remove it from the site or to use the default value
/**
* Global variables used throughout the site
* @property {string} username - The username displayed on the site
* @property {string} rootUrl - The root URL of the site
* @property {string} shortDescription - A short description of the site
* @property {string} longDescription - A longer description of the site
* @property {string} githubProfile - The GitHub profile URL
* @property {string} giteaProfile - The Gitea profile URL
* @property {string} linkedinProfile - The LinkedIn profile URL
* @property {string} articlesName - The name used for articles
* @property {string} projectsName - The name used for projects
* @property {string} viewAll - The text used for "View All" links
* @property {string} noArticles - The text used when there are no articles
* @property {string} noProjects - The text used when there are no projects
* @property {string} blogTitle - The title of the blog section
* @property {string} blogShortDescription - A short description of the blog
* @property {string} blogLongDescription - A longer description of the blog
* @property {string} projectTitle - The title of the projects section
* @property {string} projectShortDescription - A short description of the projects
* @property {string} projectLongDescription - A longer description of the projects
* @property {string} profileImage - The profile image filename
* @property {Object} menu - The menu items
*/
export const GLOBAL = {
// Site metadata
username: "Justin Deal",
@ -9,8 +32,8 @@ export const GLOBAL = {
// Social media links
githubProfile: "https://github.com/justindeal",
twitterProfile: "https://twitter.com/",
linkedinProfile: "https://www.linkedin.com/",
giteaProfile: "https://code.justin.deal/dealjus",
linkedinProfile: "https://www.linkedin.com/in/justin-deal/",
// Common text names used throughout the site
articlesName: "Articles",

View File

@ -7,7 +7,6 @@ import Section from "../../components/common/Section.astro";
import SearchBar from "../../components/common/SearchBar.astro";
import { articles } from "../../lib/list";
import { countTags } from "../../lib/utils";
import { initializeSearch } from "../../components/common/searchUtils.js";
const tagCounts = countTags(articles.map((article) => article.tags).flat().filter((tag): tag is string => tag !== undefined));
@ -39,7 +38,7 @@ const tagCounts = countTags(articles.map((article) => article.tags).flat().filte
<link rel="canonical" href={`${GLOBAL.rootUrl}/blog`} />
</Fragment>
<!-- Search functionality is provided by search-client.js -->
<!-- Search functionality is provided by SearchScript.astro -->
<Section class="my-8">
<div x-data="searchArticles" x-init="init()" x-cloak>

View File

@ -3,36 +3,57 @@ import { GLOBAL } from "../../lib/variables";
import Layout from "../../layouts/Layout.astro";
import Section from "../../components/common/Section.astro";
import SearchBar from "../../components/common/SearchBar.astro";
import StyleControls from "../../components/common/StyleControls.astro";
import StyleControlsScript from "../../components/StyleControlsScript.astro";
import CategorySection from "../../components/homelab/CategorySection.astro";
import ServiceCardSkeleton from "../../components/common/ServiceCardSkeleton.astro";
import SkeletonLoader from "../../components/common/SkeletonLoader.astro";
import SEO from "../../components/SEO.astro";
import StructuredData from "../../components/StructuredData.astro";
import { services } from "./services.ts";
import { initializeSearch } from "../../components/common/searchUtils.js";
// Count total services
const totalServices = Object.values(services).reduce(
(count, serviceList) => count + serviceList.length,
0
);
// Structured data for the homelab page
const webpageData = {
name: "Homelab Dashboard",
description: `A collection of ${totalServices} self-hosted services and applications running on my personal homelab.`,
url: `${GLOBAL.rootUrl}/homelab`,
isPartOf: {
"@type": "WebSite",
"name": `${GLOBAL.username} • ${GLOBAL.shortDescription}`,
"url": GLOBAL.rootUrl
}
};
---
<Layout>
<Fragment slot="head">
<title>{GLOBAL.username} • {GLOBAL.shortDescription}</title>
<meta name="description" content={GLOBAL.longDescription} />
<meta property="og:title" content={`${GLOBAL.username} • ${GLOBAL.shortDescription}`} />
<meta property="og:description" content={GLOBAL.longDescription} />
<meta property="og:image" content={`${GLOBAL.rootUrl}/pixel_avatar.png`} />
<meta property="og:url" content={GLOBAL.rootUrl} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={`${GLOBAL.username} • ${GLOBAL.shortDescription}`} />
<meta name="twitter:description" content={GLOBAL.longDescription} />
<meta name="twitter:image" content={`${GLOBAL.rootUrl}/pixel_avatar.png`} />
<meta http-equiv="content-language" content="en" />
<meta name="language" content="English" />
<link rel="canonical" href={GLOBAL.rootUrl} />
</Fragment>
<SEO
slot="head"
title="Homelab Dashboard"
description={`A collection of ${totalServices} self-hosted services and applications running on my personal homelab.`}
canonicalUrl={`${GLOBAL.rootUrl}/homelab`}
/>
<StructuredData slot="head" type="WebPage" data={webpageData} />
<!-- Search functionality is provided by search-client.js -->
<!-- Search functionality is provided by SearchScript.astro -->
<!-- Keyboard shortcuts for style controls -->
<StyleControlsScript />
<Section class="my-8">
<Section class="my-2">
<div x-data="searchServices" x-init="init()" x-cloak>
<!-- Search container - positioned at the top -->
<!-- Search and controls container -->
<div class="mb-4 pt-0">
<!-- Style controls in a centered row above search -->
<div class="w-full flex justify-center mb-4">
<StyleControls />
</div>
<!-- Search bar below style controls -->
<div class="w-full">
<SearchBar
placeholder="Search services..."

View File

@ -1,7 +1,12 @@
export const services = {
import { type Service, type ServiceCategory } from "../../lib/types";
/**
* Services available in the homelab, organized by category
*/
export const services: ServiceCategory = {
Websites: [
{
name: "https://justin.deal",
name: "justin.deal",
link: "https://justin.deal",
icon: "/pixel_avatar.png",
alt: "Personal Website"

View File

@ -6,39 +6,37 @@ import Hero from "../components/home/Hero.astro";
import Section from "../components/common/Section.astro";
import FeaturedProjects from "../components/home/FeaturedProjects.astro";
import FeaturedArticles from "../components/home/FeaturedArticles.astro";
import SEO from "../components/SEO.astro";
import StructuredData from "../components/StructuredData.astro";
// Structured data for the home page
const websiteData = {
name: `${GLOBAL.username} • ${GLOBAL.shortDescription}`,
url: GLOBAL.rootUrl,
description: GLOBAL.longDescription,
potentialAction: {
"@type": "SearchAction",
"target": `${GLOBAL.rootUrl}/search?q={search_term_string}`,
"query-input": "required name=search_term_string"
}
};
const personData = {
name: GLOBAL.username,
url: GLOBAL.rootUrl,
jobTitle: "Software Developer",
sameAs: [
GLOBAL.githubProfile,
GLOBAL.linkedinProfile,
GLOBAL.giteaProfile
]
};
---
<Layout>
<Fragment slot="head">
<title>{GLOBAL.username} • {GLOBAL.shortDescription}</title>
<meta
name="description"
content={GLOBAL.longDescription}
/>
<meta
property="og:title"
content={`${GLOBAL.username} • ${GLOBAL.shortDescription}`}
/>
<meta
property="og:description"
content={GLOBAL.longDescription}
/>
<meta property="og:image" content={`${GLOBAL.rootUrl}/${GLOBAL.profileImage}`} />
<meta property="og:url" content={GLOBAL.rootUrl} />
<meta name="twitter:card" content="summary_large_image" />
<meta
name="twitter:title"
content={`${GLOBAL.username} • ${GLOBAL.shortDescription}`}
/>
<meta
name="twitter:description"
content={GLOBAL.longDescription}
/>
<meta name="twitter:image" content={`${GLOBAL.rootUrl}/${GLOBAL.profileImage}`} />
<meta http-equiv="content-language" content="en" />
<meta name="language" content="English" />
<link rel="canonical" href={GLOBAL.rootUrl} />
</Fragment>
<SEO slot="head" />
<StructuredData slot="head" type="WebSite" data={websiteData} />
<StructuredData slot="head" type="Person" data={personData} />
<Section class="my-16">
<Hero />
</Section>

View File

@ -6,7 +6,6 @@ import ProjectSnippetSkeleton from "../../components/ProjectSnippetSkeleton.astr
import SearchBar from "../../components/common/SearchBar.astro";
import Layout from "../../layouts/Layout.astro";
import { GLOBAL } from "../../lib/variables";
import { initializeSearch } from "../../components/common/searchUtils.js";
---
<Layout>
@ -35,7 +34,7 @@ import { initializeSearch } from "../../components/common/searchUtils.js";
<link rel="canonical" href={`${GLOBAL.rootUrl}/projects`} />
</Fragment>
<!-- Search functionality is provided by search-client.js -->
<!-- Search functionality is provided by SearchScript.astro -->
<Section class="py-4 my-8">
<div x-data="searchProjects" x-init="init()" x-cloak>

View File

@ -2,8 +2,8 @@
layout: ../../layouts/ProjectLayout.astro
title: This Website
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
tags: ["astro", "typescript", "tailwindcss", "alpinejs", "gruvbox-theme"]
githubUrl: https://code.justin.deal/dealjus/justin.deal
timestamp: 2025-02-24T02:39:03+00:00
featured: true
filename: this-site

View File

@ -0,0 +1,116 @@
/**
* Theme Transition Script
*
* Handles element-specific animations during theme changes.
* Applies staggered animations to different elements when the theme changes.
*/
document.addEventListener('DOMContentLoaded', () => {
// Listen for theme change events
window.addEventListener('theme-changed', (e) => {
const customEvent = e instanceof CustomEvent ? e : null;
const theme = customEvent?.detail?.theme || (document.documentElement.classList.contains('dark') ? 'dark' : 'light');
// Skip animations if reduced motion is preferred
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
return;
}
// Animate headings with staggered delay
document.querySelectorAll('h1, h2, h3').forEach((heading, index) => {
// Remove any existing animation classes
heading.classList.remove('theme-animate-slide');
// Force a reflow to restart the animation
void heading.offsetWidth;
// Add animation class with delay based on index
setTimeout(() => {
heading.classList.add('theme-animate-slide');
}, 50 + (index * 30));
});
// Animate cards and sections with staggered delay
document.querySelectorAll('.card, article, section:not(section section)').forEach((element, index) => {
// Remove any existing animation classes
element.classList.remove('theme-animate-scale');
// Force a reflow to restart the animation
void element.offsetWidth;
// Add animation class with delay based on index
setTimeout(() => {
element.classList.add('theme-animate-scale');
}, 100 + (index * 40));
});
// Animate images and icons
document.querySelectorAll('img, svg').forEach((element, index) => {
// Remove any existing animation classes
element.classList.remove('theme-animate-fade');
// Force a reflow to restart the animation
void element.offsetWidth;
// Add animation class with delay based on index
setTimeout(() => {
element.classList.add('theme-animate-fade');
}, 150 + (index * 20));
});
// Add theme-specific classes to enhance certain elements
if (theme === 'dark') {
// Dark theme enhancements
document.querySelectorAll('code, pre').forEach(element => {
element.classList.add('dark-theme-code');
});
// Add subtle glow to important buttons in dark mode
document.querySelectorAll('.button-primary, .cta-button').forEach(element => {
element.classList.add('dark-theme-glow');
});
} else {
// Light theme enhancements
document.querySelectorAll('code, pre').forEach(element => {
element.classList.remove('dark-theme-code');
});
// Remove glow from buttons in light mode
document.querySelectorAll('.button-primary, .cta-button').forEach(element => {
element.classList.remove('dark-theme-glow');
});
}
});
});
// Add CSS classes for theme-specific enhancements
const addThemeStyles = () => {
// Create a style element
const style = document.createElement('style');
// Add CSS for dark theme code blocks
style.textContent = `
.dark-theme-code {
box-shadow: 0 0 8px rgba(254, 128, 25, 0.3);
}
.dark-theme-glow {
box-shadow: 0 0 12px rgba(254, 128, 25, 0.4);
}
@media (prefers-reduced-motion: reduce) {
.theme-animate-fade,
.theme-animate-slide,
.theme-animate-scale {
animation: none !important;
transition: none !important;
}
}
`;
// Append the style element to the head
document.head.appendChild(style);
};
// Add the styles when the DOM is ready
document.addEventListener('DOMContentLoaded', addThemeStyles);

View File

@ -8,9 +8,34 @@ html:not(.theme-loaded) body {
display: none;
}
/* Ensure smooth transitions between themes */
/* Enhanced transitions between themes */
html.theme-loaded body {
transition: background-color 0.3s ease, color 0.3s ease;
transition: background-color 0.5s cubic-bezier(0.4, 0, 0.2, 1),
color 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Add transition to all themed elements */
.theme-transition-element {
transition: background-color 0.5s cubic-bezier(0.4, 0, 0.2, 1),
color 0.5s cubic-bezier(0.4, 0, 0.2, 1),
border-color 0.5s cubic-bezier(0.4, 0, 0.2, 1),
fill 0.5s cubic-bezier(0.4, 0, 0.2, 1),
stroke 0.5s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.5s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Font loading states */
html:not(.fonts-loaded) body {
/* Fallback font metrics that match your custom font */
font-family: monospace;
}
html.fonts-loaded body {
/* Your custom font */
font-family: var(--font-mono);
/* Add a subtle transition for font changes */
transition: font-family 0.1s ease-out;
}
/* Hide elements with x-cloak until Alpine.js is loaded */
@ -24,10 +49,11 @@ html.theme-loaded body {
outline-offset: 2px;
}
/* Font declarations with optimized loading strategies */
@font-face {
font-family: "Literata Variable";
font-style: normal;
font-display: swap;
font-display: swap; /* Use swap for text fonts */
font-weight: 200 900;
src: url(https://cdn.jsdelivr.net/fontsource/fonts/literata:vf@latest/latin-opsz-normal.woff2)
format("woff2-variations");
@ -39,7 +65,7 @@ html.theme-loaded body {
@font-face {
font-family: "press-start-2p";
font-style: normal;
font-display: swap;
font-display: optional; /* Use optional for decorative fonts */
font-weight: 400;
src: url(https://cdn.jsdelivr.net/fontsource/fonts/press-start-2p@latest/latin-400-normal.woff2)
format("woff2"),
@ -65,6 +91,11 @@ html.theme-loaded body {
--color-zag-accent-light-muted: #a89984; /* button2 */
--color-zag-accent-dark: #fe8019; /* secondary */
--color-zag-accent-dark-muted: #fabd2f; /* tertiary */
/* Card hover effect variables */
--color-zag-bg: rgba(235, 219, 178, 0.8); /* Light mode card background */
--color-zag-bg-hover: rgba(235, 219, 178, 1); /* Light mode card hover background */
--color-zag-accent: rgba(184, 187, 38, 0.5); /* Light mode accent border */
/* Additional special colors */
--color-zag-button-primary: #b8bb26;
@ -86,17 +117,134 @@ html.theme-loaded body {
--zag-transition-duration: 0.15s;
--zag-transition-timing-function: ease-in-out;
}
.zag-transition {
@media (prefers-reduced-motion: no-preference) {
transition:
background-color var(--zag-transition-duration) var(--zag-transition-timing-function),
color var(--zag-transition-duration) var(--zag-transition-timing-function),
fill var(--zag-transition-duration) var(--zag-transition-timing-function),
border-color var(--zag-transition-duration) var(--zag-transition-timing-function),
transform var(--zag-transition-duration) var(--zag-transition-timing-function);
}
.dark {
--color-zag-bg: rgba(40, 40, 40, 0.8); /* Dark mode card background */
--color-zag-bg-hover: rgba(40, 40, 40, 1); /* Dark mode card hover background */
--color-zag-accent: rgba(254, 128, 25, 0.5); /* Dark mode accent border */
}
/* Interactive element base transitions */
.zag-interactive {
position: relative;
transition: all 0.2s ease;
transform-origin: center;
}
/* Standard hover effect for buttons and interactive elements */
.zag-interactive:hover {
transform: translateY(-2px);
}
/* Active/pressed state */
.zag-interactive:active {
transform: translateY(0);
}
/* Focus state for keyboard navigation */
.zag-interactive:focus-visible {
outline: 2px solid var(--color-zag-accent-dark);
outline-offset: 2px;
}
/* Button hover effects */
.zag-button {
position: relative;
overflow: hidden;
}
.zag-button::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: currentColor;
opacity: 0;
transition: opacity 0.2s ease;
}
.zag-button:hover::after {
opacity: 0.1;
}
.zag-button:active::after {
opacity: 0.2;
}
/* Link hover effects */
.zag-link {
position: relative;
}
.zag-link::after {
content: '';
position: absolute;
bottom: -2px;
left: 0;
width: 0;
height: 2px;
background-color: currentColor;
transition: width 0.2s ease;
}
.zag-link:hover::after {
width: 100%;
}
.zag-transition {
@media (prefers-reduced-motion: no-preference) {
transition:
background-color var(--zag-transition-duration) var(--zag-transition-timing-function),
color var(--zag-transition-duration) var(--zag-transition-timing-function),
fill var(--zag-transition-duration) var(--zag-transition-timing-function),
border-color var(--zag-transition-duration) var(--zag-transition-timing-function),
transform var(--zag-transition-duration) var(--zag-transition-timing-function),
opacity var(--zag-transition-duration) var(--zag-transition-timing-function),
box-shadow var(--zag-transition-duration) var(--zag-transition-timing-function);
}
}
/* Theme transition animations for specific elements */
@keyframes theme-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes theme-slide-up {
from {
opacity: 0.5;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes theme-scale-in {
from {
opacity: 0.8;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.theme-animate-fade {
animation: theme-fade-in 0.5s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
.theme-animate-slide {
animation: theme-slide-up 0.5s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
.theme-animate-scale {
animation: theme-scale-in 0.5s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
/* Base backgrounds and text */
.zag-bg {

View File

@ -3,6 +3,16 @@
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"],
"compilerOptions": {
"allowImportingTsExtensions": true
"allowImportingTsExtensions": true,
"exactOptionalPropertyTypes": false, // Temporarily disabled to fix component issues
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noPropertyAccessFromIndexSignature": true,
"noUncheckedIndexedAccess": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}