Compare commits
6 Commits
c6573287fe
...
ca9cd27acd
Author | SHA1 | Date | |
---|---|---|---|
ca9cd27acd | |||
fe566f9e1a | |||
9179145830 | |||
fb7ef4c464 | |||
583461d25c | |||
4ea6a4daa7 |
@ -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
@ -1 +1,2 @@
|
||||
/// <reference types="astro/client" />
|
||||
/// <reference path="content.d.ts" />
|
@ -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'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
12
node_modules/.vite/deps/_metadata.json
generated
vendored
@ -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
|
||||
}
|
||||
},
|
||||
|
BIN
public/android-chrome-192x192.png
Normal file
After Width: | Height: | Size: 2.5 MiB |
BIN
public/android-chrome-512x512.png
Normal file
After Width: | Height: | Size: 2.5 MiB |
BIN
public/favicons/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 2.5 MiB |
BIN
public/favicons/favicon-16x16.png
Normal file
After Width: | Height: | Size: 2.5 MiB |
BIN
public/favicons/favicon-32x32.png
Normal file
After Width: | Height: | Size: 2.5 MiB |
BIN
public/favicons/favicon.png
Normal file
After Width: | Height: | Size: 2.5 MiB |
252
public/offline.html
Normal 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>
|
0
public/patterns/light-pattern.svg
Normal file
225
public/service-worker.js
Normal 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
@ -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": "/"
|
||||
}
|
BIN
src/assets/images/michael-dam-unsplash.webp
Normal file
After Width: | Height: | Size: 195 KiB |
BIN
src/assets/images/pixel_avatar.png
Normal file
After Width: | Height: | Size: 2.5 MiB |
BIN
src/assets/images/zaggonaut.png
Normal file
After Width: | Height: | Size: 956 KiB |
@ -9,18 +9,11 @@ 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">
|
||||
<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"
|
||||
@ -32,8 +25,10 @@ const year = date.getFullYear();
|
||||
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">
|
||||
</Anchor>
|
||||
)}
|
||||
{GLOBAL.giteaProfile && (
|
||||
<Anchor url={GLOBAL.giteaProfile} aria-label="Gitea Profile">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
@ -45,7 +40,8 @@ const year = date.getFullYear();
|
||||
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>)}
|
||||
</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
@ -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} />
|
@ -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
|
||||
|
88
src/components/StructuredData.astro
Normal 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} />
|
66
src/components/StyleControlsScript.astro
Normal 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>
|
96
src/components/ThemeBackground.astro
Normal 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>
|
88
src/components/ThemeScheduler.astro
Normal 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>
|
@ -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">
|
||||
<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"
|
||||
>
|
||||
<!-- Sun icon for light mode -->
|
||||
<svg
|
||||
width="30px"
|
||||
class="theme-icon sun-icon"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
name="Theme toggle"
|
||||
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>
|
||||
|
||||
<!-- 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>
|
||||
</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");
|
||||
<!-- 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>
|
||||
|
||||
const isDark = element.classList.contains("dark");
|
||||
localStorage.setItem("theme", isDark ? "dark" : "light");
|
||||
<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>
|
||||
|
||||
// Dispatch a custom event that other components can listen for
|
||||
window.dispatchEvent(new CustomEvent('theme-changed', {
|
||||
detail: { theme: isDark ? 'dark' : 'light' }
|
||||
}));
|
||||
};
|
||||
<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>
|
||||
|
||||
// Add event listener when the DOM is ready
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
94
src/components/ThemeTransitionEffect.astro
Normal 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>
|
@ -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"}
|
||||
|
116
src/components/common/AnimatedElement.astro
Normal 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>
|
183
src/components/common/FormInput.astro
Normal 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>
|
126
src/components/common/LoadingIndicator.astro
Normal 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>
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
77
src/components/common/OptimizedImage.astro
Normal 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>
|
248
src/components/common/ResponsiveImage.astro
Normal 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>
|
135
src/components/common/ScrollReveal.astro
Normal 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>
|
@ -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"
|
||||
|
@ -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"
|
||||
>
|
||||
<div class="service-icon-container flex-shrink-0">
|
||||
<img
|
||||
src={img}
|
||||
alt={alt}
|
||||
class="w-16 h-16"
|
||||
class="service-icon w-16 h-16 transition-all duration-300"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<p class="mt-2 text-center">{name}</p>
|
||||
</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>
|
||||
|
166
src/components/common/StyleControls.astro
Normal 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>
|
@ -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'
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
@ -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}
|
||||
<AnimatedElement animation="scale" duration={600}>
|
||||
<OptimizedImage
|
||||
src={profileImage}
|
||||
alt={GLOBAL.username}
|
||||
class="rounded-full max-w-40 w-40 h-40 mb-4 z-0 grayscale"
|
||||
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>
|
||||
<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>
|
||||
|
@ -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,13 +65,19 @@ 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 (
|
||||
<ScrollReveal
|
||||
animation="fade-up"
|
||||
delay={100 * apps.indexOf(app)}
|
||||
duration={500}
|
||||
threshold={0.1}
|
||||
>
|
||||
<div
|
||||
class="app-card transition-all duration-300"
|
||||
data-app-name={appName}
|
||||
@ -63,6 +91,7 @@ const categoryLower = category.toLowerCase();
|
||||
alt={app.name}
|
||||
/>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
|
@ -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
@ -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;
|
||||
}
|
@ -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
|
||||
href="https://fonts.bunny.net/css?family=ibm-plex-mono:400,400i,500,500i,600,600i,700,700i"
|
||||
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&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 />
|
||||
|
@ -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[];
|
||||
};
|
||||
|
@ -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",
|
||||
|
@ -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>
|
||||
|
@ -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..."
|
||||
|
@ -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"
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
116
src/scripts/ThemeTransition.js
Normal 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);
|
@ -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"),
|
||||
@ -66,6 +92,11 @@ html.theme-loaded body {
|
||||
--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;
|
||||
--color-zag-button-secondary: #a89984;
|
||||
@ -87,16 +118,133 @@ html.theme-loaded body {
|
||||
--zag-transition-timing-function: ease-in-out;
|
||||
}
|
||||
|
||||
.zag-transition {
|
||||
.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);
|
||||
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 {
|
||||
|
@ -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/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|