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 types="astro/client" />
|
||||||
|
/// <reference path="content.d.ts" />
|
@ -5,7 +5,60 @@ import tailwindcss from "@tailwindcss/vite";
|
|||||||
// https://astro.build/config
|
// https://astro.build/config
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
site: 'https://justin.deal',
|
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: {
|
vite: {
|
||||||
plugins: [tailwindcss()],
|
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",
|
"hash": "22066863",
|
||||||
"configHash": "2cd4a4ea",
|
"configHash": "21423e47",
|
||||||
"lockfileHash": "53cd0e09",
|
"lockfileHash": "53cd0e09",
|
||||||
"browserHash": "f4d46c12",
|
"browserHash": "ad44e8cf",
|
||||||
"optimized": {
|
"optimized": {
|
||||||
"astro > cssesc": {
|
"astro > cssesc": {
|
||||||
"src": "../../.pnpm/cssesc@3.0.0/node_modules/cssesc/cssesc.js",
|
"src": "../../.pnpm/cssesc@3.0.0/node_modules/cssesc/cssesc.js",
|
||||||
"file": "astro___cssesc.js",
|
"file": "astro___cssesc.js",
|
||||||
"fileHash": "dc615560",
|
"fileHash": "35f908c6",
|
||||||
"needsInterop": true
|
"needsInterop": true
|
||||||
},
|
},
|
||||||
"astro > aria-query": {
|
"astro > aria-query": {
|
||||||
"src": "../../.pnpm/aria-query@5.3.2/node_modules/aria-query/lib/index.js",
|
"src": "../../.pnpm/aria-query@5.3.2/node_modules/aria-query/lib/index.js",
|
||||||
"file": "astro___aria-query.js",
|
"file": "astro___aria-query.js",
|
||||||
"fileHash": "53d05d83",
|
"fileHash": "13f7590c",
|
||||||
"needsInterop": true
|
"needsInterop": true
|
||||||
},
|
},
|
||||||
"astro > axobject-query": {
|
"astro > axobject-query": {
|
||||||
"src": "../../.pnpm/axobject-query@4.1.0/node_modules/axobject-query/lib/index.js",
|
"src": "../../.pnpm/axobject-query@4.1.0/node_modules/axobject-query/lib/index.js",
|
||||||
"file": "astro___axobject-query.js",
|
"file": "astro___axobject-query.js",
|
||||||
"fileHash": "35e7ec58",
|
"fileHash": "59c979c2",
|
||||||
"needsInterop": true
|
"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,43 +9,39 @@ const year = date.getFullYear();
|
|||||||
|
|
||||||
<footer class="mt-16 mb-8">
|
<footer class="mt-16 mb-8">
|
||||||
<Section class="mb-4">
|
<Section class="mb-4">
|
||||||
<div
|
<div class="flex flex-col gap-6">
|
||||||
class="zag-border-b zag-transition pb-4 flex flex-col sm:flex-row sm:flex-nowrap gap-8 sm:items-center"
|
<!-- Social icons row - now above the links -->
|
||||||
>
|
<div class="flex justify-center gap-4">
|
||||||
<ul class="flex flex-row gap-2">
|
{GLOBAL.githubProfile && (
|
||||||
{Object.entries(GLOBAL.menu).map((i) => (
|
<Anchor url={GLOBAL.githubProfile} aria-label="GitHub Profile">
|
||||||
<li>
|
<svg
|
||||||
<Anchor url={i[1]}>{i[0]}</Anchor>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
</li>
|
width="32"
|
||||||
))}
|
height="32"
|
||||||
</ul>
|
viewBox="0 0 24 24"
|
||||||
<div class="flex flex-row sm:flex-row gap-4">
|
>
|
||||||
{GLOBAL.githubProfile && (<Anchor url={GLOBAL.githubProfile} aria-label="GitHub Profile">
|
<path
|
||||||
<svg
|
class="zag-fill zag-transition"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
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"
|
||||||
width="32"
|
></path>
|
||||||
height="32"
|
</svg>
|
||||||
viewBox="0 0 24 24"
|
</Anchor>
|
||||||
>
|
)}
|
||||||
<path
|
{GLOBAL.giteaProfile && (
|
||||||
class="zag-fill zag-transition"
|
<Anchor url={GLOBAL.giteaProfile} aria-label="Gitea Profile">
|
||||||
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"
|
<svg
|
||||||
></path>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
</svg>
|
width="32"
|
||||||
</Anchor>)}
|
height="32"
|
||||||
{GLOBAL.giteaProfile && (<Anchor url={GLOBAL.giteaProfile} aria-label="Gitea Profile">
|
viewBox="0 0 24 24"
|
||||||
<svg
|
>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<path
|
||||||
width="32"
|
class="zag-fill zag-transition"
|
||||||
height="32"
|
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"
|
||||||
viewBox="0 0 24 24"
|
></path>
|
||||||
>
|
</svg>
|
||||||
<path
|
</Anchor>
|
||||||
class="zag-fill zag-transition"
|
)}
|
||||||
d="M12 2A10 10 0 0 0 2 12c0 4.42 2.87 8.17 6.84 9.5c.5.08.66-.23.66-.5v-1.69c-2.77.6-3.36-1.34-3.36-1.34c-.46-1.16-1.11-1.47-1.11-1.47c-.91-.62.07-.6.07-.6c1 .07 1.53 1.03 1.53 1.03c.87 1.52 2.34 1.07 2.91.83c.09-.65.35-1.09.63-1.34c-2.22-.25-4.55-1.11-4.55-4.92c0-1.11.38-2 1.03-2.71c-.1-.25-.45-1.29.1-2.64c0 0 .84-.27 2.75 1.02c.79-.22 1.65-.33 2.5-.33s1.71.11 2.5.33c1.91-1.29 2.75-1.02 2.75-1.02c.55 1.35.2 2.39.1 2.64c.65.71 1.03 1.6 1.03 2.71c0 3.82-2.34 4.66-4.57 4.91c.36.31.69.92.69 1.85V21c0 .27.16.59.67.5C19.14 20.16 22 16.42 22 12A10 10 0 0 0 12 2"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
</Anchor>)}
|
|
||||||
{GLOBAL.linkedinProfile && (
|
{GLOBAL.linkedinProfile && (
|
||||||
<Anchor url={GLOBAL.linkedinProfile} aria-label="LinkedIn Profile">
|
<Anchor url={GLOBAL.linkedinProfile} aria-label="LinkedIn Profile">
|
||||||
<svg
|
<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"
|
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>
|
></path>
|
||||||
</svg>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</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', () => {
|
document.addEventListener('alpine:init', () => {
|
||||||
// Homelab search
|
// Homelab search
|
||||||
window.Alpine.data('searchServices', () => {
|
window.Alpine.data('searchServices', () => {
|
||||||
return initializeSearch('.app-card', {
|
const baseSearch = initializeSearch('.app-card', {
|
||||||
nameAttribute: 'data-app-name',
|
nameAttribute: 'data-app-name',
|
||||||
tagsAttribute: 'data-app-tags',
|
tagsAttribute: 'data-app-tags',
|
||||||
categoryAttribute: 'data-app-category',
|
categoryAttribute: 'data-app-category',
|
||||||
@ -241,6 +241,152 @@ document.addEventListener('alpine:init', () => {
|
|||||||
resultCountMessage: (count) => `Found ${count} services`,
|
resultCountMessage: (count) => `Found ${count} services`,
|
||||||
itemLabel: '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
|
// 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
|
||||||
|
*/
|
||||||
|
---
|
||||||
|
|
||||||
---
|
<div class="theme-toggle-container" x-data="{ open: false }">
|
||||||
|
<button
|
||||||
<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">
|
class="theme-toggle-button zag-interactive"
|
||||||
<svg
|
id="themeToggle"
|
||||||
width="30px"
|
aria-label="Theme Toggle"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
@click="open = !open"
|
||||||
viewBox="0 0 24 24"
|
|
||||||
name="Theme toggle"
|
|
||||||
>
|
>
|
||||||
<path
|
<!-- Sun icon for light mode -->
|
||||||
class="zag-transition fill-neutral-900 dark:fill-transparent"
|
<svg
|
||||||
fill-rule="evenodd"
|
class="theme-icon sun-icon"
|
||||||
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"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
></path>
|
width="24"
|
||||||
<path
|
height="24"
|
||||||
class="zag-transition fill-transparent dark:fill-neutral-100"
|
viewBox="0 0 24 24"
|
||||||
fill-rule="evenodd"
|
aria-hidden="true"
|
||||||
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>
|
<path
|
||||||
</svg>
|
class="zag-transition fill-neutral-900 dark:fill-transparent"
|
||||||
</button>
|
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>
|
||||||
|
|
||||||
<script is:inline>
|
<!-- Moon icon for dark mode -->
|
||||||
// Theme toggle functionality - works with the flash prevention script in Layout
|
<svg
|
||||||
const handleToggleClick = () => {
|
class="theme-icon moon-icon"
|
||||||
const element = document.documentElement;
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
element.classList.toggle("dark");
|
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>
|
||||||
|
|
||||||
const isDark = element.classList.contains("dark");
|
<!-- Dropdown menu -->
|
||||||
localStorage.setItem("theme", isDark ? "dark" : "light");
|
<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>
|
||||||
|
|
||||||
// Dispatch a custom event that other components can listen for
|
<button class="theme-option" id="darkTheme">
|
||||||
window.dispatchEvent(new CustomEvent('theme-changed', {
|
<svg class="theme-option-icon" viewBox="0 0 24 24" width="16" height="16">
|
||||||
detail: { theme: isDark ? 'dark' : 'light' }
|
<!-- 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>
|
||||||
|
|
||||||
// Add event listener when the DOM is ready
|
<button class="theme-option" id="systemTheme">
|
||||||
|
<svg class="theme-option-icon" viewBox="0 0 24 24" width="16" height="16">
|
||||||
|
<!-- System icon -->
|
||||||
|
<rect x="2" y="3" width="20" height="14" rx="2" stroke="currentColor" stroke-width="2" fill="none"></rect>
|
||||||
|
<path d="M8 21h8M12 17v4" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path>
|
||||||
|
</svg>
|
||||||
|
<span>System Preference</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="theme-option" id="autoTheme">
|
||||||
|
<svg class="theme-option-icon" viewBox="0 0 24 24" width="16" height="16">
|
||||||
|
<!-- Clock icon -->
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none"></circle>
|
||||||
|
<path d="M12 6v6l4 2" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path>
|
||||||
|
</svg>
|
||||||
|
<span>Time-Based</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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.addEventListener('DOMContentLoaded', () => {
|
||||||
document
|
const lightThemeBtn = document.getElementById('lightTheme');
|
||||||
.getElementById("themeToggle")
|
const darkThemeBtn = document.getElementById('darkTheme');
|
||||||
?.addEventListener("click", handleToggleClick);
|
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>
|
</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
|
<a
|
||||||
href={url}
|
href={url}
|
||||||
class:list={[
|
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,
|
className,
|
||||||
]}
|
]}
|
||||||
target={external ? "_blank" : "_self"}
|
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 {
|
interface Props {
|
||||||
message?: string;
|
message?: string;
|
||||||
subMessage?: string;
|
subMessage?: string;
|
||||||
@ -18,14 +20,16 @@ const {
|
|||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
>
|
>
|
||||||
<div class="loading-content text-center p-8 rounded-lg">
|
<div class="loading-content text-center p-8 rounded-lg">
|
||||||
<!-- Spinner -->
|
<!-- Enhanced loading indicator -->
|
||||||
<div class="spinner-container mb-6 flex justify-center">
|
<div class="mb-6 flex justify-center">
|
||||||
<div class="spinner"></div>
|
<LoadingIndicator type="dots" size="large" color="var(--color-zag-accent-dark)" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Messages -->
|
<!-- Messages with animation -->
|
||||||
<p class="text-xl font-semibold mb-2 zag-text">{message}</p>
|
<div class="messages-container">
|
||||||
<p class="text-base zag-text-muted">{subMessage}</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -41,26 +45,23 @@ const {
|
|||||||
visibility: visible;
|
visibility: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.spinner {
|
/* Message animations */
|
||||||
width: 48px;
|
.message-animate {
|
||||||
height: 48px;
|
animation: fadeSlideUp 0.6s ease-out both;
|
||||||
border: 4px solid var(--color-zag-dark-muted);
|
|
||||||
border-radius: 50%;
|
|
||||||
border-top-color: var(--color-zag-accent-dark);
|
|
||||||
animation: spin 1.5s cubic-bezier(0.68, -0.55, 0.27, 1.55) infinite;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.dark) .spinner {
|
.message-animate-delay {
|
||||||
border-color: var(--color-zag-light-muted);
|
animation: fadeSlideUp 0.6s ease-out 0.2s both;
|
||||||
border-top-color: var(--color-zag-accent-dark);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes fadeSlideUp {
|
||||||
0% {
|
from {
|
||||||
transform: rotate(0deg);
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
}
|
}
|
||||||
100% {
|
to {
|
||||||
transform: rotate(360deg);
|
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}`}>
|
<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 -->
|
<!-- Hidden live region for screen readers -->
|
||||||
<div
|
<div
|
||||||
id="search-status"
|
id="search-status"
|
||||||
@ -26,7 +35,7 @@ const {
|
|||||||
<div class="relative">
|
<div class="relative">
|
||||||
<label for="app-search" class="sr-only">{ariaLabel}</label>
|
<label for="app-search" class="sr-only">{ariaLabel}</label>
|
||||||
<!-- Search icon -->
|
<!-- 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">
|
<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" />
|
<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>
|
</svg>
|
||||||
@ -40,7 +49,7 @@ const {
|
|||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
aria-describedby="search-status search-hint"
|
aria-describedby="search-status search-hint"
|
||||||
aria-controls="app-list"
|
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
|
<button
|
||||||
x-show="searchQuery"
|
x-show="searchQuery"
|
||||||
@ -55,7 +64,7 @@ const {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tooltip area - shows either help text or status text -->
|
<!-- 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 -->
|
<!-- Keyboard shortcut hint - shown when search is empty -->
|
||||||
<div
|
<div
|
||||||
id="search-hint"
|
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;
|
const { name, href, img, alt } = Astro.props;
|
||||||
---
|
---
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href={href}
|
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"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
<img
|
<div class="service-icon-container flex-shrink-0">
|
||||||
src={img}
|
<img
|
||||||
alt={alt}
|
src={img}
|
||||||
class="w-16 h-16"
|
alt={alt}
|
||||||
loading="lazy"
|
class="service-icon w-16 h-16 transition-all duration-300"
|
||||||
decoding="async"
|
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>
|
</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 { 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">
|
<div class="flex flex-col items-center sm:flex-row gap-8">
|
||||||
<img
|
<AnimatedElement animation="scale" duration={600}>
|
||||||
src={GLOBAL.profileImage}
|
<OptimizedImage
|
||||||
alt={GLOBAL.username}
|
src={profileImage}
|
||||||
class="rounded-full max-w-40 w-40 h-40 mb-4 z-0 grayscale"
|
alt={GLOBAL.username}
|
||||||
/>
|
width={160}
|
||||||
|
height={160}
|
||||||
|
class="rounded-full max-w-40 w-40 h-40 mb-4 z-0 grayscale hover:grayscale-0 transition-all duration-500"
|
||||||
|
/>
|
||||||
|
</AnimatedElement>
|
||||||
<div>
|
<div>
|
||||||
<h1
|
<AnimatedElement animation="slide-up" duration={500} delay={200}>
|
||||||
class="text-3xl sm:text-4xl font-display font-semibold opsz text-center sm:text-left"
|
<h1
|
||||||
>
|
class="text-3xl sm:text-4xl font-display font-semibold opsz text-center sm:text-left"
|
||||||
{GLOBAL.username}
|
>
|
||||||
</h1>
|
{GLOBAL.username}
|
||||||
<h2
|
</h1>
|
||||||
class="text-center text-xl sm:text-2xl font-mono font-medium sm:text-left"
|
</AnimatedElement>
|
||||||
>
|
<AnimatedElement animation="slide-up" duration={500} delay={400}>
|
||||||
<p set:html={GLOBAL.shortDescription}/>
|
<h2
|
||||||
</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>
|
||||||
</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 {
|
interface Props {
|
||||||
|
/**
|
||||||
|
* The category name to display as the section title
|
||||||
|
*/
|
||||||
category: string;
|
category: string;
|
||||||
apps: Array<{
|
|
||||||
name: string;
|
/**
|
||||||
link: string;
|
* Array of service objects to display in this category
|
||||||
icon: string;
|
*/
|
||||||
alt: string;
|
apps: Service[];
|
||||||
tags?: string[];
|
|
||||||
}>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { category, apps } = Astro.props;
|
const { category, apps } = Astro.props;
|
||||||
import ServiceCard from "../common/ServiceCard.astro";
|
|
||||||
|
|
||||||
// Pre-compute values during server-side rendering
|
// Pre-compute values during server-side rendering
|
||||||
const categoryId = `category-${category.toLowerCase().replace(/\s+/g, '-')}`;
|
const categoryId = `category-${category.toLowerCase().replace(/\s+/g, '-')}`;
|
||||||
@ -43,26 +65,33 @@ const categoryLower = category.toLowerCase();
|
|||||||
x-transition
|
x-transition
|
||||||
id={categoryId}
|
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.length > 0 ? (
|
||||||
apps.map(app => {
|
apps.map(app => {
|
||||||
const appName = app.name.toLowerCase();
|
const appName = app.name.toLowerCase();
|
||||||
const appTags = app.tags ? app.tags.join(' ').toLowerCase() : '';
|
const appTags = app.tags ? app.tags.join(' ').toLowerCase() : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<ScrollReveal
|
||||||
class="app-card transition-all duration-300"
|
animation="fade-up"
|
||||||
data-app-name={appName}
|
delay={100 * apps.indexOf(app)}
|
||||||
data-app-tags={appTags}
|
duration={500}
|
||||||
data-app-category={categoryLower}
|
threshold={0.1}
|
||||||
>
|
>
|
||||||
<ServiceCard
|
<div
|
||||||
name={app.name}
|
class="app-card transition-all duration-300"
|
||||||
href={app.link}
|
data-app-name={appName}
|
||||||
img={app.icon}
|
data-app-tags={appTags}
|
||||||
alt={app.name}
|
data-app-category={categoryLower}
|
||||||
/>
|
>
|
||||||
</div>
|
<ServiceCard
|
||||||
|
name={app.name}
|
||||||
|
href={app.link}
|
||||||
|
img={app.icon}
|
||||||
|
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 Header from "../components/Header.astro";
|
||||||
import SearchScript from "../components/SearchScript.astro";
|
import SearchScript from "../components/SearchScript.astro";
|
||||||
import LoadingOverlay from "../components/common/LoadingOverlay.astro";
|
import 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";
|
import "../styles/global.css";
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -38,16 +41,90 @@ import "../styles/global.css";
|
|||||||
document.documentElement.classList.add('theme-loaded');
|
document.documentElement.classList.add('theme-loaded');
|
||||||
})();
|
})();
|
||||||
</script>
|
</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 charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<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} />
|
<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
|
<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"
|
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 />
|
<SearchScript />
|
||||||
<slot name="head" />
|
<slot name="head" />
|
||||||
</head>
|
</head>
|
||||||
@ -55,6 +132,10 @@ import "../styles/global.css";
|
|||||||
<!-- Loading overlay for long loading times -->
|
<!-- Loading overlay for long loading times -->
|
||||||
<LoadingOverlay />
|
<LoadingOverlay />
|
||||||
|
|
||||||
|
<!-- Theme-specific background patterns and transition effect -->
|
||||||
|
<ThemeBackground />
|
||||||
|
<ThemeTransitionEffect />
|
||||||
|
|
||||||
<Header />
|
<Header />
|
||||||
<main>
|
<main>
|
||||||
<slot />
|
<slot />
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Represents the frontmatter for a project
|
||||||
|
*/
|
||||||
export type ProjectFrontmatter = {
|
export type ProjectFrontmatter = {
|
||||||
/**
|
/**
|
||||||
* The title of the project
|
* The title of the project
|
||||||
@ -43,6 +46,9 @@ export type ProjectFrontmatter = {
|
|||||||
filename: string;
|
filename: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the frontmatter for an article
|
||||||
|
*/
|
||||||
export type ArticleFrontmatter = {
|
export type ArticleFrontmatter = {
|
||||||
/**
|
/**
|
||||||
* The title of the article
|
* The title of the article
|
||||||
@ -50,7 +56,7 @@ export type ArticleFrontmatter = {
|
|||||||
title: string;
|
title: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* THe summary description of the article
|
* The summary description of the article
|
||||||
*/
|
*/
|
||||||
description: string;
|
description: string;
|
||||||
|
|
||||||
@ -81,3 +87,40 @@ export type ArticleFrontmatter = {
|
|||||||
*/
|
*/
|
||||||
filename: string;
|
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
|
// 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 = {
|
export const GLOBAL = {
|
||||||
// Site metadata
|
// Site metadata
|
||||||
username: "Justin Deal",
|
username: "Justin Deal",
|
||||||
@ -9,8 +32,8 @@ export const GLOBAL = {
|
|||||||
|
|
||||||
// Social media links
|
// Social media links
|
||||||
githubProfile: "https://github.com/justindeal",
|
githubProfile: "https://github.com/justindeal",
|
||||||
twitterProfile: "https://twitter.com/",
|
giteaProfile: "https://code.justin.deal/dealjus",
|
||||||
linkedinProfile: "https://www.linkedin.com/",
|
linkedinProfile: "https://www.linkedin.com/in/justin-deal/",
|
||||||
|
|
||||||
// Common text names used throughout the site
|
// Common text names used throughout the site
|
||||||
articlesName: "Articles",
|
articlesName: "Articles",
|
||||||
|
@ -7,7 +7,6 @@ import Section from "../../components/common/Section.astro";
|
|||||||
import SearchBar from "../../components/common/SearchBar.astro";
|
import SearchBar from "../../components/common/SearchBar.astro";
|
||||||
import { articles } from "../../lib/list";
|
import { articles } from "../../lib/list";
|
||||||
import { countTags } from "../../lib/utils";
|
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));
|
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`} />
|
<link rel="canonical" href={`${GLOBAL.rootUrl}/blog`} />
|
||||||
</Fragment>
|
</Fragment>
|
||||||
|
|
||||||
<!-- Search functionality is provided by search-client.js -->
|
<!-- Search functionality is provided by SearchScript.astro -->
|
||||||
|
|
||||||
<Section class="my-8">
|
<Section class="my-8">
|
||||||
<div x-data="searchArticles" x-init="init()" x-cloak>
|
<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 Layout from "../../layouts/Layout.astro";
|
||||||
import Section from "../../components/common/Section.astro";
|
import Section from "../../components/common/Section.astro";
|
||||||
import SearchBar from "../../components/common/SearchBar.astro";
|
import SearchBar from "../../components/common/SearchBar.astro";
|
||||||
|
import StyleControls from "../../components/common/StyleControls.astro";
|
||||||
|
import StyleControlsScript from "../../components/StyleControlsScript.astro";
|
||||||
import CategorySection from "../../components/homelab/CategorySection.astro";
|
import CategorySection from "../../components/homelab/CategorySection.astro";
|
||||||
import ServiceCardSkeleton from "../../components/common/ServiceCardSkeleton.astro";
|
import ServiceCardSkeleton from "../../components/common/ServiceCardSkeleton.astro";
|
||||||
import SkeletonLoader from "../../components/common/SkeletonLoader.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 { 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>
|
<Layout>
|
||||||
<Fragment slot="head">
|
<SEO
|
||||||
<title>{GLOBAL.username} • {GLOBAL.shortDescription}</title>
|
slot="head"
|
||||||
<meta name="description" content={GLOBAL.longDescription} />
|
title="Homelab Dashboard"
|
||||||
<meta property="og:title" content={`${GLOBAL.username} • ${GLOBAL.shortDescription}`} />
|
description={`A collection of ${totalServices} self-hosted services and applications running on my personal homelab.`}
|
||||||
<meta property="og:description" content={GLOBAL.longDescription} />
|
canonicalUrl={`${GLOBAL.rootUrl}/homelab`}
|
||||||
<meta property="og:image" content={`${GLOBAL.rootUrl}/pixel_avatar.png`} />
|
/>
|
||||||
<meta property="og:url" content={GLOBAL.rootUrl} />
|
<StructuredData slot="head" type="WebPage" data={webpageData} />
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- 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>
|
<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">
|
<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">
|
<div class="w-full">
|
||||||
<SearchBar
|
<SearchBar
|
||||||
placeholder="Search services..."
|
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: [
|
Websites: [
|
||||||
{
|
{
|
||||||
name: "https://justin.deal",
|
name: "justin.deal",
|
||||||
link: "https://justin.deal",
|
link: "https://justin.deal",
|
||||||
icon: "/pixel_avatar.png",
|
icon: "/pixel_avatar.png",
|
||||||
alt: "Personal Website"
|
alt: "Personal Website"
|
||||||
|
@ -6,39 +6,37 @@ import Hero from "../components/home/Hero.astro";
|
|||||||
import Section from "../components/common/Section.astro";
|
import Section from "../components/common/Section.astro";
|
||||||
import FeaturedProjects from "../components/home/FeaturedProjects.astro";
|
import FeaturedProjects from "../components/home/FeaturedProjects.astro";
|
||||||
import FeaturedArticles from "../components/home/FeaturedArticles.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>
|
<Layout>
|
||||||
<Fragment slot="head">
|
<SEO slot="head" />
|
||||||
<title>{GLOBAL.username} • {GLOBAL.shortDescription}</title>
|
<StructuredData slot="head" type="WebSite" data={websiteData} />
|
||||||
<meta
|
<StructuredData slot="head" type="Person" data={personData} />
|
||||||
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>
|
|
||||||
<Section class="my-16">
|
<Section class="my-16">
|
||||||
<Hero />
|
<Hero />
|
||||||
</Section>
|
</Section>
|
||||||
|
@ -6,7 +6,6 @@ import ProjectSnippetSkeleton from "../../components/ProjectSnippetSkeleton.astr
|
|||||||
import SearchBar from "../../components/common/SearchBar.astro";
|
import SearchBar from "../../components/common/SearchBar.astro";
|
||||||
import Layout from "../../layouts/Layout.astro";
|
import Layout from "../../layouts/Layout.astro";
|
||||||
import { GLOBAL } from "../../lib/variables";
|
import { GLOBAL } from "../../lib/variables";
|
||||||
import { initializeSearch } from "../../components/common/searchUtils.js";
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout>
|
<Layout>
|
||||||
@ -35,7 +34,7 @@ import { initializeSearch } from "../../components/common/searchUtils.js";
|
|||||||
<link rel="canonical" href={`${GLOBAL.rootUrl}/projects`} />
|
<link rel="canonical" href={`${GLOBAL.rootUrl}/projects`} />
|
||||||
</Fragment>
|
</Fragment>
|
||||||
|
|
||||||
<!-- Search functionality is provided by search-client.js -->
|
<!-- Search functionality is provided by SearchScript.astro -->
|
||||||
|
|
||||||
<Section class="py-4 my-8">
|
<Section class="py-4 my-8">
|
||||||
<div x-data="searchProjects" x-init="init()" x-cloak>
|
<div x-data="searchProjects" x-init="init()" x-cloak>
|
||||||
|
@ -2,8 +2,8 @@
|
|||||||
layout: ../../layouts/ProjectLayout.astro
|
layout: ../../layouts/ProjectLayout.astro
|
||||||
title: This Website
|
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.
|
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"]
|
tags: ["astro", "typescript", "tailwindcss", "alpinejs", "gruvbox-theme"]
|
||||||
githubUrl: https://code.justin.deal
|
githubUrl: https://code.justin.deal/dealjus/justin.deal
|
||||||
timestamp: 2025-02-24T02:39:03+00:00
|
timestamp: 2025-02-24T02:39:03+00:00
|
||||||
featured: true
|
featured: true
|
||||||
filename: this-site
|
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;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ensure smooth transitions between themes */
|
/* Enhanced transitions between themes */
|
||||||
html.theme-loaded body {
|
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 */
|
/* Hide elements with x-cloak until Alpine.js is loaded */
|
||||||
@ -24,10 +49,11 @@ html.theme-loaded body {
|
|||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Font declarations with optimized loading strategies */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Literata Variable";
|
font-family: "Literata Variable";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: swap; /* Use swap for text fonts */
|
||||||
font-weight: 200 900;
|
font-weight: 200 900;
|
||||||
src: url(https://cdn.jsdelivr.net/fontsource/fonts/literata:vf@latest/latin-opsz-normal.woff2)
|
src: url(https://cdn.jsdelivr.net/fontsource/fonts/literata:vf@latest/latin-opsz-normal.woff2)
|
||||||
format("woff2-variations");
|
format("woff2-variations");
|
||||||
@ -39,7 +65,7 @@ html.theme-loaded body {
|
|||||||
@font-face {
|
@font-face {
|
||||||
font-family: "press-start-2p";
|
font-family: "press-start-2p";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: optional; /* Use optional for decorative fonts */
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
src: url(https://cdn.jsdelivr.net/fontsource/fonts/press-start-2p@latest/latin-400-normal.woff2)
|
src: url(https://cdn.jsdelivr.net/fontsource/fonts/press-start-2p@latest/latin-400-normal.woff2)
|
||||||
format("woff2"),
|
format("woff2"),
|
||||||
@ -66,6 +92,11 @@ html.theme-loaded body {
|
|||||||
--color-zag-accent-dark: #fe8019; /* secondary */
|
--color-zag-accent-dark: #fe8019; /* secondary */
|
||||||
--color-zag-accent-dark-muted: #fabd2f; /* tertiary */
|
--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 */
|
/* Additional special colors */
|
||||||
--color-zag-button-primary: #b8bb26;
|
--color-zag-button-primary: #b8bb26;
|
||||||
--color-zag-button-secondary: #a89984;
|
--color-zag-button-secondary: #a89984;
|
||||||
@ -87,17 +118,134 @@ html.theme-loaded body {
|
|||||||
--zag-transition-timing-function: ease-in-out;
|
--zag-transition-timing-function: ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zag-transition {
|
.dark {
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
--color-zag-bg: rgba(40, 40, 40, 0.8); /* Dark mode card background */
|
||||||
transition:
|
--color-zag-bg-hover: rgba(40, 40, 40, 1); /* Dark mode card hover background */
|
||||||
background-color var(--zag-transition-duration) var(--zag-transition-timing-function),
|
--color-zag-accent: rgba(254, 128, 25, 0.5); /* Dark mode accent border */
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Interactive element base transitions */
|
||||||
|
.zag-interactive {
|
||||||
|
position: relative;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Standard hover effect for buttons and interactive elements */
|
||||||
|
.zag-interactive:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active/pressed state */
|
||||||
|
.zag-interactive:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus state for keyboard navigation */
|
||||||
|
.zag-interactive:focus-visible {
|
||||||
|
outline: 2px solid var(--color-zag-accent-dark);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button hover effects */
|
||||||
|
.zag-button {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zag-button::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: currentColor;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zag-button:hover::after {
|
||||||
|
opacity: 0.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zag-button:active::after {
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Link hover effects */
|
||||||
|
.zag-link {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zag-link::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: -2px;
|
||||||
|
left: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 2px;
|
||||||
|
background-color: currentColor;
|
||||||
|
transition: width 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zag-link:hover::after {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zag-transition {
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
transition:
|
||||||
|
background-color var(--zag-transition-duration) var(--zag-transition-timing-function),
|
||||||
|
color var(--zag-transition-duration) var(--zag-transition-timing-function),
|
||||||
|
fill var(--zag-transition-duration) var(--zag-transition-timing-function),
|
||||||
|
border-color var(--zag-transition-duration) var(--zag-transition-timing-function),
|
||||||
|
transform var(--zag-transition-duration) var(--zag-transition-timing-function),
|
||||||
|
opacity var(--zag-transition-duration) var(--zag-transition-timing-function),
|
||||||
|
box-shadow var(--zag-transition-duration) var(--zag-transition-timing-function);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Theme transition animations for specific elements */
|
||||||
|
@keyframes theme-fade-in {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes theme-slide-up {
|
||||||
|
from {
|
||||||
|
opacity: 0.5;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes theme-scale-in {
|
||||||
|
from {
|
||||||
|
opacity: 0.8;
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-animate-fade {
|
||||||
|
animation: theme-fade-in 0.5s cubic-bezier(0.4, 0, 0.2, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-animate-slide {
|
||||||
|
animation: theme-slide-up 0.5s cubic-bezier(0.4, 0, 0.2, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-animate-scale {
|
||||||
|
animation: theme-scale-in 0.5s cubic-bezier(0.4, 0, 0.2, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
/* Base backgrounds and text */
|
/* Base backgrounds and text */
|
||||||
.zag-bg {
|
.zag-bg {
|
||||||
background-color: var(--color-zag-light);
|
background-color: var(--color-zag-light);
|
||||||
|
@ -3,6 +3,16 @@
|
|||||||
"include": [".astro/types.d.ts", "**/*"],
|
"include": [".astro/types.d.ts", "**/*"],
|
||||||
"exclude": ["dist"],
|
"exclude": ["dist"],
|
||||||
"compilerOptions": {
|
"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/*"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|