Update Component structure

This commit is contained in:
Justin Deal 2025-05-03 18:22:25 -07:00
parent 647d98e6b6
commit 750fe5c629
20 changed files with 3283 additions and 1082 deletions

View File

@ -3,15 +3,27 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="You are currently offline. Some pages may still be available if you've visited them before.">
<title>Offline | Justin Deal</title> <title>Offline | Justin Deal</title>
<link rel="icon" href="/favicons/favicon.png" type="image/png">
<link rel="apple-touch-icon" href="/favicons/apple-touch-icon.png">
<style> <style>
:root { :root {
--color-bg: #fbf1c7; --color-bg: #fbf1c7;
--color-text: #3c3836; --color-text: #3c3836;
--color-accent: #d65d0e; --color-accent: #fe8019;
--color-accent-secondary: #b8bb26;
--color-muted: #7c6f64; --color-muted: #7c6f64;
--font-mono: 'IBM Plex Mono', monospace; --color-card: rgba(235, 219, 178, 0.8);
--font-display: 'Press Start 2P', monospace; --color-card-hover: rgba(235, 219, 178, 1);
--font-mono: 'IBM Plex Mono', monospace, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
--font-display: 'Press Start 2P', monospace, system-ui;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 1rem;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
@ -19,10 +31,14 @@
--color-bg: #282828; --color-bg: #282828;
--color-text: #ebdbb2; --color-text: #ebdbb2;
--color-accent: #fe8019; --color-accent: #fe8019;
--color-accent-secondary: #b8bb26;
--color-muted: #a89984; --color-muted: #a89984;
--color-card: rgba(40, 40, 40, 0.8);
--color-card-hover: rgba(40, 40, 40, 1);
} }
} }
/* Base styles */
* { * {
box-sizing: border-box; box-sizing: border-box;
margin: 0; margin: 0;
@ -38,6 +54,7 @@
flex-direction: column; flex-direction: column;
min-height: 100vh; min-height: 100vh;
padding: 2rem; padding: 2rem;
transition: background-color 0.3s ease, color 0.3s ease;
} }
main { main {
@ -53,55 +70,109 @@
h1 { h1 {
font-family: var(--font-display); font-family: var(--font-display);
font-size: 2rem; font-size: clamp(1.5rem, 5vw, 2.5rem);
margin-bottom: 1rem;
color: var(--color-accent);
line-height: 1.3;
}
h2 {
font-size: clamp(1.1rem, 3vw, 1.5rem);
margin-bottom: 1rem; margin-bottom: 1rem;
color: var(--color-accent); color: var(--color-accent);
} }
p { p {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
font-size: 1.1rem; font-size: clamp(0.9rem, 2vw, 1.1rem);
max-width: 600px;
} }
.offline-icon { .offline-icon {
font-size: 4rem; font-size: clamp(3rem, 10vw, 5rem);
margin-bottom: 2rem; margin-bottom: 2rem;
animation: pulse 2s infinite; animation: pulse 2s infinite;
position: relative;
}
.offline-icon::after {
content: '';
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
background: radial-gradient(circle, rgba(254, 128, 25, 0.2) 0%, rgba(254, 128, 25, 0) 70%);
border-radius: 50%;
z-index: -1;
animation: pulse-shadow 2s infinite;
} }
.button { .button {
display: inline-block; display: inline-flex;
align-items: center;
justify-content: center;
background-color: var(--color-accent); background-color: var(--color-accent);
color: var(--color-bg); color: var(--color-bg);
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
border-radius: 0.25rem; border-radius: var(--radius-md);
text-decoration: none; text-decoration: none;
font-weight: bold; font-weight: bold;
transition: transform 0.2s, box-shadow 0.2s; transition: transform 0.2s, box-shadow 0.2s, background-color 0.2s;
cursor: pointer; cursor: pointer;
margin-top: 1rem; margin-top: 1rem;
border: none;
font-family: var(--font-mono);
font-size: 1rem;
min-width: 180px;
} }
.button:hover { .button:hover {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-md);
background-color: var(--color-accent-secondary);
}
.button:active {
transform: translateY(0);
}
.button:focus {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
.button-icon {
margin-right: 0.5rem;
}
.card {
background-color: var(--color-card);
border-radius: var(--radius-md);
padding: 1.5rem;
margin-top: 2rem;
width: 100%;
max-width: 500px;
box-shadow: var(--shadow-sm);
transition: transform 0.2s, box-shadow 0.2s, background-color 0.2s;
}
.card:hover {
background-color: var(--color-card-hover);
box-shadow: var(--shadow-md);
} }
.cached-pages { .cached-pages {
margin-top: 2rem;
text-align: left; text-align: left;
width: 100%; width: 100%;
max-width: 400px;
}
.cached-pages h2 {
font-size: 1.2rem;
margin-bottom: 1rem;
color: var(--color-accent);
} }
.cached-pages ul { .cached-pages ul {
list-style: none; list-style: none;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.75rem;
margin-top: 1rem;
} }
.cached-pages li { .cached-pages li {
@ -113,37 +184,126 @@
text-decoration: none; text-decoration: none;
border-bottom: 1px solid var(--color-accent); border-bottom: 1px solid var(--color-accent);
padding-bottom: 2px; padding-bottom: 2px;
display: inline-block;
transition: color 0.2s, transform 0.2s;
} }
.cached-pages a:hover { .cached-pages a:hover {
color: var(--color-accent); color: var(--color-accent);
transform: translateX(3px);
} }
.status-indicator {
display: inline-flex;
align-items: center;
padding: 0.5rem 1rem;
background-color: rgba(251, 73, 52, 0.2);
color: #fb4934;
border-radius: var(--radius-md);
font-size: 0.875rem;
margin-bottom: 2rem;
}
.status-indicator.online {
background-color: rgba(184, 187, 38, 0.2);
color: #b8bb26;
}
.status-indicator-icon {
margin-right: 0.5rem;
}
.footer {
margin-top: 3rem;
text-align: center;
font-size: 0.875rem;
color: var(--color-muted);
}
.footer a {
color: var(--color-accent);
text-decoration: none;
}
/* Animations */
@keyframes pulse { @keyframes pulse {
0% { 0% {
opacity: 1; opacity: 1;
transform: scale(1);
} }
50% { 50% {
opacity: 0.6; opacity: 0.7;
transform: scale(0.95);
} }
100% { 100% {
opacity: 1; opacity: 1;
transform: scale(1);
}
}
@keyframes pulse-shadow {
0% {
opacity: 0.7;
transform: scale(1);
}
50% {
opacity: 0.3;
transform: scale(1.1);
}
100% {
opacity: 0.7;
transform: scale(1);
}
}
/* Responsive adjustments */
@media (max-width: 600px) {
body {
padding: 1.5rem;
}
.cached-pages ul {
grid-template-columns: 1fr;
}
}
/* Reduced motion preference */
@media (prefers-reduced-motion: reduce) {
.offline-icon,
.offline-icon::after,
.button,
.cached-pages a {
animation: none;
transition: none;
} }
} }
</style> </style>
</head> </head>
<body> <body>
<main> <main>
<div class="offline-icon">📶</div> <div class="status-indicator">
<h1>You're Offline</h1> <span class="status-indicator-icon">⚠️</span>
<p>It looks like you've lost your internet connection. Some pages may still be available if you've visited them before.</p> <span>You are currently offline</span>
<button class="button" id="retry-button">Retry Connection</button> </div>
<div class="cached-pages" id="cached-pages"> <div class="offline-icon">📶</div>
<h1>No Internet Connection</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">
<span class="button-icon">🔄</span>
<span>Retry Connection</span>
</button>
<div class="card" id="cached-pages">
<h2>Available Pages</h2> <h2>Available Pages</h2>
<p>Loading cached pages...</p> <p>Loading cached pages...</p>
<ul id="cached-pages-list"></ul> <ul id="cached-pages-list"></ul>
</div> </div>
<div class="footer">
<p>&copy; 2025 <a href="/">Justin Deal</a> | <a href="javascript:void(0)" id="clear-cache-button">Clear Cache</a></p>
</div>
</main> </main>
<script> <script>
@ -154,13 +314,35 @@
// Update UI based on connection status // Update UI based on connection status
function updateConnectionStatus() { function updateConnectionStatus() {
const statusIndicator = document.querySelector('.status-indicator');
const statusIcon = document.querySelector('.status-indicator-icon');
const statusText = statusIndicator.querySelector('span:last-child');
if (checkConnection()) { if (checkConnection()) {
// We're back online, reload the page // We're back online
statusIndicator.classList.add('online');
statusIcon.textContent = '✅';
statusText.textContent = 'You are back online';
// Update other UI elements
document.querySelector('.offline-icon').textContent = '🌐';
document.querySelector('h1').textContent = "Connection Restored";
document.querySelector('p').textContent = "Your internet connection has been restored. You can continue browsing or reload the page to get the latest content.";
// Change retry button to reload button
const button = document.getElementById('retry-button');
button.innerHTML = '<span class="button-icon">🔄</span><span>Reload Page</span>';
button.addEventListener('click', () => {
window.location.reload(); window.location.reload();
}, { once: true });
} else { } else {
// Still offline // Still offline
statusIndicator.classList.remove('online');
statusIcon.textContent = '⚠️';
statusText.textContent = 'You are currently offline';
document.querySelector('.offline-icon').textContent = '📶'; document.querySelector('.offline-icon').textContent = '📶';
document.querySelector('h1').textContent = "You're Offline"; document.querySelector('h1').textContent = "No Internet Connection";
} }
} }
@ -168,42 +350,110 @@
window.addEventListener('online', updateConnectionStatus); window.addEventListener('online', updateConnectionStatus);
window.addEventListener('offline', updateConnectionStatus); window.addEventListener('offline', updateConnectionStatus);
// Initial status check
updateConnectionStatus();
// Retry button // Retry button
document.getElementById('retry-button').addEventListener('click', () => { document.getElementById('retry-button').addEventListener('click', () => {
const button = document.getElementById('retry-button');
button.innerHTML = '<span class="button-icon">🔄</span><span>Checking...</span>';
button.disabled = true;
document.querySelector('.offline-icon').textContent = '🔄'; document.querySelector('.offline-icon').textContent = '🔄';
document.querySelector('h1').textContent = "Checking Connection..."; document.querySelector('h1').textContent = "Checking Connection...";
// Try to fetch the homepage // Try to fetch the homepage
fetch('/') fetch('/', { cache: 'no-store' })
.then(() => { .then(() => {
// If successful, we're online // If successful, we're online
window.location.reload(); updateConnectionStatus();
button.disabled = false;
}) })
.catch(() => { .catch(() => {
// Still offline // Still offline
updateConnectionStatus(); updateConnectionStatus();
button.disabled = false;
button.innerHTML = '<span class="button-icon">🔄</span><span>Retry Connection</span>';
}); });
}); });
// List cached pages if service worker and caches are available // Clear cache button
if ('caches' in window && 'serviceWorker' in navigator) { document.getElementById('clear-cache-button').addEventListener('click', () => {
caches.open('justin-deal-v1') if ('caches' in window) {
.then(cache => { caches.keys().then(cacheNames => {
return cache.keys() return Promise.all(
.then(requests => { cacheNames.map(cacheName => {
return caches.delete(cacheName);
})
);
}).then(() => {
alert('Cache cleared successfully');
loadCachedPages(); // Refresh the cached pages list
}).catch(error => {
console.error('Error clearing cache:', error);
alert('Failed to clear cache: ' + error.message);
});
} else {
alert('Cache API not supported in your browser');
}
});
// Function to load and display cached pages
function loadCachedPages() {
const cachedPagesContainer = document.getElementById('cached-pages');
const cachedPagesList = document.getElementById('cached-pages-list'); const cachedPagesList = document.getElementById('cached-pages-list');
if (requests.length === 0) { // Check if caches API is available
document.getElementById('cached-pages').innerHTML = '<h2>No Cached Pages Available</h2><p>Try visiting some pages when you\'re back online.</p>'; if (!('caches' in window) || !('serviceWorker' in navigator)) {
cachedPagesContainer.innerHTML = '<h2>Cache Not Available</h2><p>Your browser does not support caching or service workers.</p>';
return; return;
} }
// Filter for HTML pages // Get all cache stores
const htmlRequests = requests.filter(request => { caches.keys().then(cacheNames => {
// Find caches that might contain HTML pages
const pageCaches = cacheNames.filter(name =>
name.includes('justin-deal') &&
!name.includes('metadata') &&
!name.includes('images')
);
if (pageCaches.length === 0) {
cachedPagesContainer.innerHTML = '<h2>No Cached Pages Available</h2><p>Try visiting some pages when you\'re back online.</p>';
return;
}
// Collect all cached requests from relevant caches
const allRequests = [];
Promise.all(
pageCaches.map(cacheName =>
caches.open(cacheName)
.then(cache => cache.keys())
.then(requests => {
allRequests.push(...requests);
})
)
).then(() => {
if (allRequests.length === 0) {
cachedPagesContainer.innerHTML = '<h2>No Cached Pages Available</h2><p>Try visiting some pages when you\'re back online.</p>';
return;
}
// Filter for HTML pages and remove duplicates
const uniqueUrls = new Set();
const htmlRequests = allRequests.filter(request => {
const url = new URL(request.url); const url = new URL(request.url);
return url.pathname === '/' || const isHtmlPage = url.pathname === '/' ||
url.pathname.endsWith('.html') || url.pathname.endsWith('.html') ||
!url.pathname.includes('.'); !url.pathname.includes('.');
// Only include if it's an HTML page and we haven't seen this URL before
if (isHtmlPage && !uniqueUrls.has(url.pathname)) {
uniqueUrls.add(url.pathname);
return true;
}
return false;
}); });
// Sort by URL // Sort by URL
@ -232,21 +482,21 @@
}); });
if (listItems.length === 0) { 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>'; cachedPagesContainer.innerHTML = '<h2>No Cached Pages Available</h2><p>Try visiting some pages when you\'re back online.</p>';
return; return;
} }
cachedPagesList.innerHTML = listItems.join(''); cachedPagesList.innerHTML = listItems.join('');
document.querySelector('#cached-pages p').style.display = 'none'; document.querySelector('#cached-pages p').style.display = 'none';
}); });
}) }).catch(error => {
.catch(error => {
console.error('Error accessing cache:', 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>'; cachedPagesContainer.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>';
} }
// Load cached pages on page load
loadCachedPages();
</script> </script>
</body> </body>
</html> </html>

View File

@ -0,0 +1,257 @@
/**
* Base search module that provides core search functionality
* This module can be extended for specific search implementations
*/
/**
* Initialize search functionality for any content type
* @param {string} contentSelector - CSS selector for searchable items
* @param {Object} options - Configuration options
* @returns {Object} Alpine.js data object with search functionality
*/
export function initializeBaseSearch(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',
debounceTime: 150 // ms to debounce search input
};
const config = { ...defaults, ...options };
return {
searchQuery: '',
hasResults: true,
visibleCount: 0,
loading: false,
focusedItemIndex: -1,
debounceTimeout: null,
init() {
// Initialize the visible count
this.visibleCount = document.querySelectorAll(contentSelector).length;
this.setupWatchers();
this.setupKeyboardShortcuts();
// Handle theme changes
window.addEventListener('theme-changed', () => {
this.filterContent(this.searchQuery);
});
},
setupWatchers() {
this.$watch('searchQuery', (query) => {
// Debounce search for better performance
if (this.debounceTimeout) {
clearTimeout(this.debounceTimeout);
}
this.debounceTimeout = setTimeout(() => {
this.filterContent(query);
}, config.debounceTime);
});
},
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();
this.focusedItemIndex = -1;
this.clearItemFocus();
}
// Arrow key navigation through results
if (this.searchQuery && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
e.preventDefault();
this.handleArrowNavigation(e.key);
}
// Enter key selects the focused item
if (e.key === 'Enter' && this.focusedItemIndex >= 0) {
this.handleEnterSelection();
}
});
},
handleArrowNavigation(key) {
const visibleItems = this.getVisibleItems();
if (visibleItems.length === 0) return;
// Update focused item index
if (key === 'ArrowDown') {
this.focusedItemIndex = Math.min(this.focusedItemIndex + 1, visibleItems.length - 1);
} else {
this.focusedItemIndex = Math.max(this.focusedItemIndex - 1, -1);
}
// Clear previous focus
this.clearItemFocus();
// If we're back at -1, focus the search input
if (this.focusedItemIndex === -1) {
document.getElementById('app-search').focus();
return;
}
// Focus the new item
const itemToFocus = visibleItems[this.focusedItemIndex];
this.focusItem(itemToFocus);
},
handleEnterSelection() {
const visibleItems = this.getVisibleItems();
if (visibleItems.length === 0) return;
const selectedItem = visibleItems[this.focusedItemIndex];
const link = selectedItem.querySelector('a');
if (link) {
link.click();
}
},
getVisibleItems() {
return Array.from(document.querySelectorAll(contentSelector))
.filter(item => item.style.display !== 'none');
},
clearItemFocus() {
// Remove focus styling from all items
document.querySelectorAll(`${contentSelector}.keyboard-focus`).forEach(item => {
item.classList.remove('keyboard-focus');
});
},
focusItem(item) {
// Add focus styling
item.classList.add('keyboard-focus');
// Scroll into view with options for smooth scrolling
const scrollBehavior = window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth';
item.scrollIntoView({ behavior: scrollBehavior, block: 'nearest' });
},
filterContent(query) {
query = query.toLowerCase().trim();
let anyResults = false;
let visibleCount = 0;
// Process all content items
document.querySelectorAll(contentSelector).forEach((item) => {
const isMatch = this.isItemMatch(item, query);
if (isMatch) {
item.style.display = '';
anyResults = true;
visibleCount++;
} else {
item.style.display = 'none';
}
});
// Update category visibility if applicable
this.updateCategoryVisibility(query);
// Update parent containers if needed
this.updateContainerVisibility(query);
this.updateResultsStatus(query, anyResults, visibleCount);
},
isItemMatch(item, query) {
// If query is empty, show all items
if (query === '') {
return true;
}
// 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);
});
// Check if any attribute matches the query
return name.includes(query) ||
tags.includes(query) ||
category.includes(query) ||
additionalMatches;
},
updateCategoryVisibility(query) {
// Only proceed if we have category sections
const categorySections = document.querySelectorAll('.category-section');
if (categorySections.length === 0) return;
// For each category section, check if it has any visible items
categorySections.forEach((categorySection) => {
const categoryId = categorySection.getAttribute('data-category');
const items = categorySection.querySelectorAll(contentSelector);
// Count visible items in this category
const visibleItems = Array.from(items).filter(item =>
item.style.display !== 'none'
).length;
// If no visible items and we're searching, hide the category
if (query !== '' && visibleItems === 0) {
categorySection.style.display = 'none';
} else {
categorySection.style.display = '';
}
});
},
updateContainerVisibility(query) {
// If there are container elements that should be hidden when empty
const containers = document.querySelectorAll('.content-container');
if (containers.length > 0) {
containers.forEach((container) => {
const hasVisibleItems = Array.from(
container.querySelectorAll(contentSelector)
).some((item) => item.style.display !== 'none');
if (query === '' || hasVisibleItems) {
container.style.display = '';
} else {
container.style.display = 'none';
}
});
}
},
updateResultsStatus(query, anyResults, count) {
// Update results status
this.hasResults = query === '' || anyResults;
this.visibleCount = count;
// Update screen reader status
const statusEl = document.getElementById('search-status');
if (statusEl) {
if (query === '') {
statusEl.textContent = config.allItemsMessage;
this.visibleCount = document.querySelectorAll(contentSelector).length;
} else if (this.hasResults) {
statusEl.textContent = config.resultCountMessage(count);
} else {
statusEl.textContent = config.noResultsMessage;
}
}
}
};
}

View File

@ -0,0 +1,38 @@
/**
* Content search module for articles and projects
* Provides specialized search functionality for content items
*/
import { initializeBaseSearch } from './baseSearch.js';
/**
* Initialize search functionality for articles
* @returns {Object} Alpine.js data object with search functionality
*/
export function initializeArticlesSearch() {
return initializeBaseSearch('.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'
});
}
/**
* Initialize search functionality for projects
* @returns {Object} Alpine.js data object with search functionality
*/
export function initializeProjectsSearch() {
return initializeBaseSearch('.project-item', {
nameAttribute: 'data-title',
tagsAttribute: 'data-tags',
additionalAttributes: ['data-description', 'data-github', 'data-live'],
noResultsMessage: 'No projects found',
allItemsMessage: 'Showing all projects',
resultCountMessage: (count) => `Found ${count} projects`,
itemLabel: 'projects'
});
}

View File

@ -0,0 +1,24 @@
/**
* Main search module that registers all search components with Alpine.js
*/
import { initializeServicesSearch } from './servicesSearch.js';
import { initializeArticlesSearch, initializeProjectsSearch } from './contentSearch.js';
/**
* Register all search components with Alpine.js
* This function is called when Alpine.js is initialized
*/
export function registerSearchComponents() {
// Register services search
window.Alpine.data('searchServices', initializeServicesSearch);
// Register articles search
window.Alpine.data('searchArticles', initializeArticlesSearch);
// Register projects search
window.Alpine.data('searchProjects', initializeProjectsSearch);
}
// Register components when Alpine.js is initialized
document.addEventListener('alpine:init', registerSearchComponents);

View File

@ -0,0 +1,243 @@
/**
* Services search module for homelab services
* Extends the base search functionality with service-specific features
*/
import { initializeBaseSearch } from './baseSearch.js';
/**
* Initialize search functionality for homelab services
* @returns {Object} Alpine.js data object with search functionality
*/
export function initializeServicesSearch() {
// Create base search with service-specific configuration
const baseSearch = initializeBaseSearch('.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'
});
// Extend with service-specific functionality
return {
...baseSearch,
// View mode properties
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() {
// Call the base init method
baseSearch.init.call(this);
// Apply initial icon size, view mode, and display mode
this.applyIconSize();
this.applyViewMode();
this.applyDisplayMode();
// Save preferences to localStorage if available
this.loadPreferences();
// Listen for window resize events to optimize layout
this.setupResizeListener();
},
// Load user preferences from localStorage
loadPreferences() {
if (typeof localStorage !== 'undefined') {
try {
// Load icon size
const savedIconSize = localStorage.getItem('services-icon-size');
if (savedIconSize) {
this.setIconSize(parseFloat(savedIconSize));
}
// Load view mode
const savedViewMode = localStorage.getItem('services-view-mode');
if (savedViewMode) {
this.setViewMode(savedViewMode);
}
// Load display mode
const savedDisplayMode = localStorage.getItem('services-display-mode');
if (savedDisplayMode) {
this.setDisplayMode(savedDisplayMode);
}
} catch (e) {
console.error('Error loading preferences:', e);
}
}
},
// Save user preferences to localStorage
savePreferences() {
if (typeof localStorage !== 'undefined') {
try {
localStorage.setItem('services-icon-size', this.iconSizeValue.toString());
localStorage.setItem('services-view-mode', this.viewMode);
localStorage.setItem('services-display-mode', this.displayMode);
} catch (e) {
console.error('Error saving preferences:', e);
}
}
},
// Setup listener for window resize events
setupResizeListener() {
const handleResize = () => {
// Switch to list view on small screens if not explicitly set by user
const userHasSetViewMode = localStorage.getItem('services-view-mode') !== null;
if (!userHasSetViewMode) {
const smallScreen = window.innerWidth < 640; // sm breakpoint
if (smallScreen && this.viewMode !== 'list') {
this.setViewMode('list', false); // Don't save to preferences
} else if (!smallScreen && this.viewMode !== 'grid') {
this.setViewMode('grid', false); // Don't save to preferences
}
}
};
// Initial check
handleResize();
// Add resize listener with debounce
let resizeTimeout;
window.addEventListener('resize', () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(handleResize, 250);
});
},
// Icon size methods
setIconSize(size, savePreference = true) {
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();
// Save preference if requested
if (savePreference) {
this.savePreferences();
}
},
// 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();
this.savePreferences();
},
setViewMode(mode, savePreference = true) {
this.viewMode = mode;
this.applyViewMode();
// Save preference if requested
if (savePreference) {
this.savePreferences();
}
},
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();
this.savePreferences();
},
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');
}
}
};
}

View File

@ -1,9 +1,27 @@
/** /**
* Service Worker for justin.deal * Service Worker for justin.deal
* Provides caching and offline support * Provides caching and offline support with advanced strategies
*/ */
const CACHE_NAME = 'justin-deal-v1'; // Cache versioning for easier updates
const CACHE_VERSION = '2';
const STATIC_CACHE_NAME = `justin-deal-static-v${CACHE_VERSION}`;
const DYNAMIC_CACHE_NAME = `justin-deal-dynamic-v${CACHE_VERSION}`;
const API_CACHE_NAME = `justin-deal-api-v${CACHE_VERSION}`;
const IMAGE_CACHE_NAME = `justin-deal-images-v${CACHE_VERSION}`;
// Cache expiration times (in milliseconds)
const CACHE_EXPIRATION = {
API: 5 * 60 * 1000, // 5 minutes
DYNAMIC: 24 * 60 * 60 * 1000, // 1 day
IMAGES: 7 * 24 * 60 * 60 * 1000 // 7 days
};
// Cache size limits
const CACHE_SIZE_LIMITS = {
DYNAMIC: 50, // items
IMAGES: 100 // items
};
// Assets to cache immediately on service worker install // Assets to cache immediately on service worker install
const PRECACHE_ASSETS = [ const PRECACHE_ASSETS = [
@ -11,6 +29,7 @@ const PRECACHE_ASSETS = [
'/index.html', '/index.html',
'/favicon.svg', '/favicon.svg',
'/site.webmanifest', '/site.webmanifest',
'/offline.html',
'/favicons/favicon.png', '/favicons/favicon.png',
'/favicons/apple-touch-icon.png', '/favicons/apple-touch-icon.png',
'/favicons/favicon-16x16.png', '/favicons/favicon-16x16.png',
@ -36,40 +55,110 @@ const ROUTE_STRATEGIES = [
// HTML pages - network first // HTML pages - network first
{ {
pattern: /\.html$|\/$/, pattern: /\.html$|\/$/,
strategy: CACHE_STRATEGIES.NETWORK_FIRST strategy: CACHE_STRATEGIES.NETWORK_FIRST,
cacheName: STATIC_CACHE_NAME
}, },
// CSS and JS - stale while revalidate // CSS and JS - stale while revalidate
{ {
pattern: /\.(css|js)$/, pattern: /\.(css|js)$/,
strategy: CACHE_STRATEGIES.STALE_WHILE_REVALIDATE strategy: CACHE_STRATEGIES.STALE_WHILE_REVALIDATE,
cacheName: STATIC_CACHE_NAME
}, },
// Images - cache first // Images - cache first
{ {
pattern: /\.(jpe?g|png|gif|svg|webp|avif)$/, pattern: /\.(jpe?g|png|gif|svg|webp|avif)$/,
strategy: CACHE_STRATEGIES.CACHE_FIRST strategy: CACHE_STRATEGIES.CACHE_FIRST,
cacheName: IMAGE_CACHE_NAME
}, },
// Fonts - cache first // Fonts - cache first
{ {
pattern: /\.(woff2?|ttf|otf|eot)$/, pattern: /\.(woff2?|ttf|otf|eot)$/,
strategy: CACHE_STRATEGIES.CACHE_FIRST strategy: CACHE_STRATEGIES.CACHE_FIRST,
cacheName: STATIC_CACHE_NAME
}, },
// API requests - network first // API requests - stale while revalidate
{ {
pattern: /\/api\//, pattern: /\/api\//,
strategy: CACHE_STRATEGIES.NETWORK_FIRST strategy: CACHE_STRATEGIES.STALE_WHILE_REVALIDATE,
cacheName: API_CACHE_NAME
} }
]; ];
// Determine cache strategy for a given URL // Determine cache strategy and cache name for a given URL
function getStrategyForUrl(url) { function getStrategyForUrl(url) {
const matchedRoute = ROUTE_STRATEGIES.find(route => route.pattern.test(url)); const matchedRoute = ROUTE_STRATEGIES.find(route => route.pattern.test(url));
return matchedRoute ? matchedRoute.strategy : CACHE_STRATEGIES.NETWORK_FIRST; return {
strategy: matchedRoute ? matchedRoute.strategy : CACHE_STRATEGIES.NETWORK_FIRST,
cacheName: matchedRoute ? matchedRoute.cacheName : DYNAMIC_CACHE_NAME
};
}
// Helper function to store cache metadata
function storeCacheMetadata(cacheName, url, metadata) {
return caches.open(`${cacheName}-metadata`)
.then(metaCache => {
return metaCache.put(
new Request(`metadata:${url}`),
new Response(JSON.stringify(metadata))
);
});
}
// Helper function to get cache metadata
async function getCacheMetadata(cacheName, url) {
const metaCache = await caches.open(`${cacheName}-metadata`);
const metadataResponse = await metaCache.match(new Request(`metadata:${url}`));
if (metadataResponse) {
return JSON.parse(await metadataResponse.text());
}
return null;
}
// Helper function to check if a cached response is expired
async function isCacheExpired(cacheName, url) {
const metadata = await getCacheMetadata(cacheName, url);
if (!metadata || !metadata.timestamp) {
return true;
}
const age = Date.now() - metadata.timestamp;
switch (cacheName) {
case API_CACHE_NAME:
return age > CACHE_EXPIRATION.API;
case DYNAMIC_CACHE_NAME:
return age > CACHE_EXPIRATION.DYNAMIC;
case IMAGE_CACHE_NAME:
return age > CACHE_EXPIRATION.IMAGES;
default:
return false; // Static cache doesn't expire
}
}
// Helper function to limit cache size
async function trimCache(cacheName, maxItems) {
const cache = await caches.open(cacheName);
const keys = await cache.keys();
if (keys.length > maxItems) {
// Delete oldest items (first in the list)
for (let i = 0; i < keys.length - maxItems; i++) {
await cache.delete(keys[i]);
// Also delete metadata
const metaCache = await caches.open(`${cacheName}-metadata`);
await metaCache.delete(new Request(`metadata:${keys[i].url}`));
}
}
} }
// Install event - precache critical assets // Install event - precache critical assets
self.addEventListener('install', event => { self.addEventListener('install', event => {
event.waitUntil( event.waitUntil(
caches.open(CACHE_NAME) caches.open(STATIC_CACHE_NAME)
.then(cache => cache.addAll(PRECACHE_ASSETS)) .then(cache => cache.addAll(PRECACHE_ASSETS))
.then(() => self.skipWaiting()) .then(() => self.skipWaiting())
); );
@ -81,7 +170,11 @@ self.addEventListener('activate', event => {
caches.keys().then(cacheNames => { caches.keys().then(cacheNames => {
return Promise.all( return Promise.all(
cacheNames cacheNames
.filter(cacheName => cacheName !== CACHE_NAME) .filter(cacheName => {
// Keep current version caches
return !cacheName.includes(`-v${CACHE_VERSION}`) &&
!cacheName.includes(`-metadata`);
})
.map(cacheName => caches.delete(cacheName)) .map(cacheName => caches.delete(cacheName))
); );
}).then(() => self.clients.claim()) }).then(() => self.clients.claim())
@ -89,15 +182,21 @@ self.addEventListener('activate', event => {
}); });
// Helper function to handle network-first strategy // Helper function to handle network-first strategy
async function networkFirstStrategy(request) { async function networkFirstStrategy(request, cacheName) {
try { try {
// Try network first // Try network first
const networkResponse = await fetch(request); const networkResponse = await fetch(request);
// If successful, clone and cache the response // If successful, clone and cache the response
if (networkResponse.ok) { if (networkResponse.ok) {
const cache = await caches.open(CACHE_NAME); const cache = await caches.open(cacheName);
cache.put(request, networkResponse.clone()); cache.put(request, networkResponse.clone());
// Store metadata with timestamp
storeCacheMetadata(cacheName, request.url, {
timestamp: Date.now(),
url: request.url
});
} }
return networkResponse; return networkResponse;
@ -114,45 +213,104 @@ async function networkFirstStrategy(request) {
} }
// Helper function to handle cache-first strategy // Helper function to handle cache-first strategy
async function cacheFirstStrategy(request) { async function cacheFirstStrategy(request, cacheName) {
// Try cache first // Try cache first
const cachedResponse = await caches.match(request); const cachedResponse = await caches.match(request);
if (cachedResponse) {
// Check if we have a cached response and if it's expired
const isExpired = cachedResponse ? await isCacheExpired(cacheName, request.url) : true;
// If we have a valid cached response, use it
if (cachedResponse && !isExpired) {
return cachedResponse; return cachedResponse;
} }
// If not in cache, get from network // If not in cache or expired, get from network
try {
const networkResponse = await fetch(request); const networkResponse = await fetch(request);
// Cache the response for future // Cache the response for future
if (networkResponse.ok) { if (networkResponse.ok) {
const cache = await caches.open(CACHE_NAME); const cache = await caches.open(cacheName);
cache.put(request, networkResponse.clone()); cache.put(request, networkResponse.clone());
// Store metadata with timestamp
storeCacheMetadata(cacheName, request.url, {
timestamp: Date.now(),
url: request.url
});
// Trim cache if needed
if (cacheName === IMAGE_CACHE_NAME) {
trimCache(IMAGE_CACHE_NAME, CACHE_SIZE_LIMITS.IMAGES);
}
} }
return networkResponse; return networkResponse;
} catch (error) {
// If network fails and we have an expired cached response, use it as fallback
if (cachedResponse) {
console.log('Using expired cached response as fallback');
return cachedResponse;
} }
// Helper function to handle stale-while-revalidate strategy // If no cache, throw error
async function staleWhileRevalidateStrategy(request) { throw error;
}
}
// Enhanced stale-while-revalidate strategy with cache expiration and metadata
async function staleWhileRevalidateStrategy(request, cacheName) {
const url = request.url;
const cache = await caches.open(cacheName);
// Try to get from cache // Try to get from cache
const cachedResponse = await caches.match(request); const cachedResponse = await cache.match(request);
// Check if we have a cached response and if it's expired
const isExpired = cachedResponse ? await isCacheExpired(cacheName, url) : true;
// If we have a cached response, use it immediately (even if expired)
// This is the "stale" part
// Fetch from network to update cache (don't await) // Fetch from network to update cache (don't await)
const fetchPromise = fetch(request) const fetchPromise = fetch(request)
.then(networkResponse => { .then(networkResponse => {
if (networkResponse.ok) { if (networkResponse.ok) {
const cache = caches.open(CACHE_NAME); // Clone the response before using it
cache.then(cache => cache.put(request, networkResponse.clone())); const responseToCache = networkResponse.clone();
// Store in cache
cache.put(request, responseToCache);
// Store metadata with timestamp
storeCacheMetadata(cacheName, url, {
timestamp: Date.now(),
url: url
});
// Trim cache if needed
if (cacheName === DYNAMIC_CACHE_NAME) {
trimCache(DYNAMIC_CACHE_NAME, CACHE_SIZE_LIMITS.DYNAMIC);
} else if (cacheName === IMAGE_CACHE_NAME) {
trimCache(IMAGE_CACHE_NAME, CACHE_SIZE_LIMITS.IMAGES);
}
} }
return networkResponse; return networkResponse;
}) })
.catch(error => { .catch(error => {
console.error('Failed to fetch and update cache:', error); console.error('Failed to fetch and update cache:', error);
// If we have a cached response but it's expired, still return it
if (cachedResponse) {
console.log('Returning expired cached response as fallback');
return cachedResponse;
}
throw error;
}); });
// Return cached response immediately if available // Return cached response immediately if available and not expired
return cachedResponse || fetchPromise; // Otherwise wait for the network response
return cachedResponse && !isExpired ? cachedResponse : fetchPromise;
} }
// Fetch event - handle all fetch requests // Fetch event - handle all fetch requests
@ -164,21 +322,21 @@ self.addEventListener('fetch', event => {
return; return;
} }
// Get the appropriate strategy for this URL // Get the appropriate strategy and cache name for this URL
const strategy = getStrategyForUrl(event.request.url); const { strategy, cacheName } = getStrategyForUrl(event.request.url);
// Apply the selected strategy // Apply the selected strategy
switch (strategy) { switch (strategy) {
case CACHE_STRATEGIES.NETWORK_FIRST: case CACHE_STRATEGIES.NETWORK_FIRST:
event.respondWith(networkFirstStrategy(event.request)); event.respondWith(networkFirstStrategy(event.request, cacheName));
break; break;
case CACHE_STRATEGIES.CACHE_FIRST: case CACHE_STRATEGIES.CACHE_FIRST:
event.respondWith(cacheFirstStrategy(event.request)); event.respondWith(cacheFirstStrategy(event.request, cacheName));
break; break;
case CACHE_STRATEGIES.STALE_WHILE_REVALIDATE: case CACHE_STRATEGIES.STALE_WHILE_REVALIDATE:
event.respondWith(staleWhileRevalidateStrategy(event.request)); event.respondWith(staleWhileRevalidateStrategy(event.request, cacheName));
break; break;
case CACHE_STRATEGIES.CACHE_ONLY: case CACHE_STRATEGIES.CACHE_ONLY:
@ -191,7 +349,7 @@ self.addEventListener('fetch', event => {
default: default:
// Default to network first // Default to network first
event.respondWith(networkFirstStrategy(event.request)); event.respondWith(networkFirstStrategy(event.request, DYNAMIC_CACHE_NAME));
} }
}); });
@ -222,4 +380,21 @@ self.addEventListener('message', event => {
if (event.data && event.data.type === 'SKIP_WAITING') { if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting(); self.skipWaiting();
} }
// Handle cache cleanup request
if (event.data && event.data.type === 'CLEAR_CACHES') {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => caches.delete(cacheName))
);
}).then(() => {
// Notify client that caches were cleared
event.ports[0].postMessage({
status: 'success',
message: 'All caches cleared successfully'
});
})
);
}
}); });

View File

@ -1,54 +1,131 @@
--- ---
---
<script is:inline>
/** /**
* Initialize search functionality for any content type * SearchScript component
* @returns {Object} Alpine.js data object with search functionality * Imports the modular 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 }; <!-- Register Alpine.js components -->
<script is:inline>
document.addEventListener('alpine:init', () => {
if (typeof Alpine !== 'undefined') {
// Register the styleControls component
Alpine.data('styleControls', () => ({
iconSize: 'medium',
viewMode: 'grid',
displayMode: 'both',
return { init() {
// Load preferences from localStorage
this.loadPreferences();
},
loadPreferences() {
if (typeof localStorage !== 'undefined') {
try {
// Load icon size
const savedIconSize = localStorage.getItem('services-icon-size');
if (savedIconSize) {
this.setIconSize(savedIconSize);
}
// Load view mode
const savedViewMode = localStorage.getItem('services-view-mode');
if (savedViewMode) {
this.viewMode = savedViewMode;
}
// Load display mode
const savedDisplayMode = localStorage.getItem('services-display-mode');
if (savedDisplayMode) {
this.displayMode = savedDisplayMode;
}
} catch (e) {
console.error('Error loading preferences:', e);
}
}
},
savePreferences() {
if (typeof localStorage !== 'undefined') {
try {
localStorage.setItem('services-icon-size', this.iconSize);
localStorage.setItem('services-view-mode', this.viewMode);
localStorage.setItem('services-display-mode', this.displayMode);
} catch (e) {
console.error('Error saving preferences:', e);
}
}
},
setIconSize(size) {
this.iconSize = size;
this.savePreferences();
},
toggleViewMode() {
this.viewMode = this.viewMode === 'grid' ? 'list' : 'grid';
this.savePreferences();
},
setDisplayMode(mode) {
this.displayMode = mode;
this.savePreferences();
}
}));
// Register searchServices component
Alpine.data('searchServices', () => ({
searchQuery: '', searchQuery: '',
hasResults: true, hasResults: true,
visibleCount: 0, visibleCount: 0,
loading: false, // Start with loading state false - the LoadingManager will control this loading: false,
focusedItemIndex: -1,
debounceTimeout: null,
// View mode properties
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
init() { init() {
// Initialize the visible count // Initialize the visible count
this.visibleCount = document.querySelectorAll(contentSelector).length; this.visibleCount = document.querySelectorAll('.app-card').length;
this.setupWatchers(); this.setupWatchers();
this.setupKeyboardShortcuts(); this.setupKeyboardShortcuts();
// Handle theme changes // Apply initial icon size, view mode, and display mode
window.addEventListener('theme-changed', () => { this.applyIconSize();
this.filterContent(this.searchQuery); this.applyViewMode();
}); this.applyDisplayMode();
// Save preferences to localStorage if available
this.loadPreferences();
// Listen for window resize events to optimize layout
this.setupResizeListener();
// Set loading to false after initialization
setTimeout(() => {
this.loading = false;
}, 300);
}, },
setupWatchers() { setupWatchers() {
this.$watch('searchQuery', (query) => { this.$watch('searchQuery', (query) => {
// Filter content immediately - no artificial delay // Debounce search for better performance
if (this.debounceTimeout) {
clearTimeout(this.debounceTimeout);
}
this.debounceTimeout = setTimeout(() => {
this.filterContent(query); this.filterContent(query);
}, 150);
}); });
}, },
setupKeyboardShortcuts() { setupKeyboardShortcuts() {
// Track the currently focused item index
this.focusedItemIndex = -1;
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
// '/' key focuses the search input // '/' key focuses the search input
if (e.key === '/' && document.activeElement.id !== 'app-search') { if (e.key === '/' && document.activeElement.id !== 'app-search') {
@ -63,239 +140,82 @@ function initializeSearch(contentSelector = '.searchable-item', options = {}) {
this.focusedItemIndex = -1; this.focusedItemIndex = -1;
this.clearItemFocus(); this.clearItemFocus();
} }
// Arrow key navigation through results
if (this.searchQuery && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
e.preventDefault();
const visibleItems = this.getVisibleItems();
if (visibleItems.length === 0) return;
// Update focused item index
if (e.key === 'ArrowDown') {
this.focusedItemIndex = Math.min(this.focusedItemIndex + 1, visibleItems.length - 1);
} else {
this.focusedItemIndex = Math.max(this.focusedItemIndex - 1, -1);
}
// Clear previous focus
this.clearItemFocus();
// If we're back at -1, focus the search input
if (this.focusedItemIndex === -1) {
document.getElementById('app-search').focus();
return;
}
// Focus the new item
const itemToFocus = visibleItems[this.focusedItemIndex];
this.focusItem(itemToFocus);
}
// Enter key selects the focused item
if (e.key === 'Enter' && this.focusedItemIndex >= 0) {
const visibleItems = this.getVisibleItems();
if (visibleItems.length === 0) return;
const selectedItem = visibleItems[this.focusedItemIndex];
const link = selectedItem.querySelector('a');
if (link) {
link.click();
}
}
}); });
}, },
getVisibleItems() { // Load user preferences from localStorage
return Array.from(document.querySelectorAll(contentSelector)) loadPreferences() {
.filter(item => item.style.display !== 'none'); if (typeof localStorage !== 'undefined') {
}, try {
// Load icon size
clearItemFocus() { const savedIconSize = localStorage.getItem('services-icon-size');
// Remove focus styling from all items if (savedIconSize) {
document.querySelectorAll(`${contentSelector}.keyboard-focus`).forEach(item => { this.setIconSize(savedIconSize);
item.classList.remove('keyboard-focus');
});
},
focusItem(item) {
// Add focus styling
item.classList.add('keyboard-focus');
item.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
},
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 category visibility for homelab page // Load view mode
this.updateCategoryVisibility(query); const savedViewMode = localStorage.getItem('services-view-mode');
if (savedViewMode) {
// Update parent containers if needed this.setViewMode(savedViewMode);
this.updateContainerVisibility(query);
this.updateResultsStatus(query, anyResults, visibleCount);
},
updateCategoryVisibility(query) {
// Only proceed if we have category sections (homelab page)
const categorySections = document.querySelectorAll('.category-section');
if (categorySections.length === 0) return;
// For each category section, check if it has any visible app cards
categorySections.forEach((categorySection) => {
const categoryId = categorySection.getAttribute('data-category');
const appCards = categorySection.querySelectorAll('.app-card');
// Count visible app cards in this category
const visibleApps = Array.from(appCards).filter(card =>
card.style.display !== 'none'
).length;
// If no visible apps and we're searching, hide the category
if (query !== '' && visibleApps === 0) {
categorySection.style.display = 'none';
} else {
categorySection.style.display = '';
} }
});
},
updateContainerVisibility(query) { // Load display mode
// If there are container elements that should be hidden when empty const savedDisplayMode = localStorage.getItem('services-display-mode');
const containers = document.querySelectorAll('.content-container'); if (savedDisplayMode) {
if (containers.length > 0) { this.setDisplayMode(savedDisplayMode);
containers.forEach((container) => { }
const hasVisibleItems = Array.from( } catch (e) {
container.querySelectorAll(contentSelector) console.error('Error loading preferences:', e);
).some((item) => item.style.display !== 'none');
if (query === '' || hasVisibleItems) {
container.style.display = '';
} else {
container.style.display = 'none';
} }
});
} }
}, },
updateResultsStatus(query, anyResults, count) { // Save user preferences to localStorage
// Update results status savePreferences() {
this.hasResults = query === '' || anyResults; if (typeof localStorage !== 'undefined') {
this.visibleCount = count; try {
localStorage.setItem('services-icon-size', this.iconSize);
// Update screen reader status localStorage.setItem('services-view-mode', this.viewMode);
const statusEl = document.getElementById('search-status'); localStorage.setItem('services-display-mode', this.displayMode);
if (statusEl) { } catch (e) {
if (query === '') { console.error('Error saving preferences:', e);
statusEl.textContent = config.allItemsMessage;
this.visibleCount = document.querySelectorAll(contentSelector).length;
} else if (this.hasResults) {
statusEl.textContent = config.resultCountMessage(count);
} else {
statusEl.textContent = config.noResultsMessage;
} }
} }
},
// Setup listener for window resize events
setupResizeListener() {
const handleResize = () => {
// Switch to list view on small screens if not explicitly set by user
const userHasSetViewMode = localStorage.getItem('services-view-mode') !== null;
if (!userHasSetViewMode) {
const smallScreen = window.innerWidth < 640; // sm breakpoint
if (smallScreen && this.viewMode !== 'list') {
this.setViewMode('list', false); // Don't save to preferences
} else if (!smallScreen && this.viewMode !== 'grid') {
this.setViewMode('grid', false); // Don't save to preferences
}
} }
}; };
}
// Register Alpine.js data components when Alpine is loaded // Initial check
document.addEventListener('alpine:init', () => { handleResize();
// Homelab search
window.Alpine.data('searchServices', () => { // Add resize listener with debounce
const baseSearch = initializeSearch('.app-card', { let resizeTimeout;
nameAttribute: 'data-app-name', window.addEventListener('resize', () => {
tagsAttribute: 'data-app-tags', clearTimeout(resizeTimeout);
categoryAttribute: 'data-app-category', resizeTimeout = setTimeout(handleResize, 250);
noResultsMessage: 'No services found',
allItemsMessage: 'Showing all services',
resultCountMessage: (count) => `Found ${count} services`,
itemLabel: 'services'
}); });
// Add icon size slider functionality
return {
...baseSearch,
iconSizeValue: 2, // Slider value: 1=small, 2=medium, 3=large
iconSize: 'medium', // small, medium, large
viewMode: 'grid', // grid or list
displayMode: 'both', // both, image, or name
debounceTimeout: null, // For debouncing slider changes
init() {
baseSearch.init.call(this);
// Apply initial icon size, view mode, and display mode
this.applyIconSize();
this.applyViewMode();
this.applyDisplayMode();
}, },
// Icon size methods // Icon size methods
setIconSize(size) { setIconSize(size) {
if (typeof size === 'string') {
// Handle legacy string values (small, medium, large)
this.iconSize = size; 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(); this.applyIconSize();
}, this.savePreferences();
// 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() { applyIconSize() {
@ -307,20 +227,23 @@ document.addEventListener('alpine:init', () => {
// Add the new size class // Add the new size class
appList.classList.add(`icon-size-${this.iconSize}`); 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 // View mode methods
toggleViewMode() { toggleViewMode() {
this.viewMode = this.viewMode === 'grid' ? 'list' : 'grid'; this.viewMode = this.viewMode === 'grid' ? 'list' : 'grid';
this.applyViewMode(); this.applyViewMode();
this.savePreferences();
}, },
setViewMode(mode) { setViewMode(mode, savePreference = true) {
this.viewMode = mode; this.viewMode = mode;
this.applyViewMode(); this.applyViewMode();
// Save preference if requested
if (savePreference) {
this.savePreferences();
}
}, },
applyViewMode() { applyViewMode() {
@ -353,6 +276,7 @@ document.addEventListener('alpine:init', () => {
setDisplayMode(mode) { setDisplayMode(mode) {
this.displayMode = mode; this.displayMode = mode;
this.applyDisplayMode(); this.applyDisplayMode();
this.savePreferences();
}, },
applyDisplayMode() { applyDisplayMode() {
@ -370,49 +294,225 @@ document.addEventListener('alpine:init', () => {
} else { } else {
appList.classList.add('display-both'); appList.classList.add('display-both');
} }
},
// Update all category sections filterContent(query) {
document.querySelectorAll('.category-section').forEach(section => { query = query.toLowerCase().trim();
const gridContainer = section.querySelector('.grid'); let anyResults = false;
if (gridContainer) { let visibleCount = 0;
// Update grid classes based on view mode
if (this.viewMode === 'grid') { // Process all content items
gridContainer.classList.remove('grid-cols-1'); document.querySelectorAll('.app-card').forEach((item) => {
gridContainer.classList.add('grid-cols-2', 'sm:grid-cols-3', 'lg:grid-cols-4'); const name = (item.getAttribute('data-app-name') || '').toLowerCase();
const tags = (item.getAttribute('data-app-tags') || '').toLowerCase();
const category = (item.getAttribute('data-app-category') || '').toLowerCase();
const isMatch = query === '' ||
name.includes(query) ||
tags.includes(query) ||
category.includes(query);
if (isMatch) {
item.style.display = '';
anyResults = true;
visibleCount++;
} else { } else {
gridContainer.classList.remove('grid-cols-2', 'sm:grid-cols-3', 'lg:grid-cols-4'); item.style.display = 'none';
gridContainer.classList.add('grid-cols-1');
} }
}
});
}
};
}); });
// Blog search // Update category visibility
window.Alpine.data('searchArticles', () => { document.querySelectorAll('.category-section').forEach((categorySection) => {
return initializeSearch('.article-item', { const items = categorySection.querySelectorAll('.app-card');
nameAttribute: 'data-title',
tagsAttribute: 'data-tags', // Count visible items in this category
additionalAttributes: ['data-description'], const visibleItems = Array.from(items).filter(item =>
noResultsMessage: 'No articles found', item.style.display !== 'none'
allItemsMessage: 'Showing all articles', ).length;
resultCountMessage: (count) => `Found ${count} articles`,
itemLabel: 'articles' // If no visible items and we're searching, hide the category
}); if (query !== '' && visibleItems === 0) {
categorySection.style.display = 'none';
} else {
categorySection.style.display = '';
}
}); });
// Projects search // Update results status
window.Alpine.data('searchProjects', () => { this.hasResults = query === '' || anyResults;
return initializeSearch('.project-item', { this.visibleCount = visibleCount;
nameAttribute: 'data-title', }
tagsAttribute: 'data-tags', }));
additionalAttributes: ['data-description', 'data-github', 'data-live'],
noResultsMessage: 'No projects found', // Register searchArticles component
allItemsMessage: 'Showing all projects', Alpine.data('searchArticles', () => ({
resultCountMessage: (count) => `Found ${count} projects`, searchQuery: '',
itemLabel: 'projects' hasResults: true,
visibleCount: 0,
loading: false,
focusedItemIndex: -1,
debounceTimeout: null,
init() {
// Initialize with loading state
this.loading = true;
// Initialize the visible count
this.visibleCount = document.querySelectorAll('.article-item').length;
// Setup watchers
this.$watch('searchQuery', (query) => {
// Debounce search for better performance
if (this.debounceTimeout) {
clearTimeout(this.debounceTimeout);
}
this.debounceTimeout = setTimeout(() => {
this.filterContent(query);
}, 150);
}); });
// Setup keyboard shortcuts
this.setupKeyboardShortcuts();
// Set loading to false after initialization
setTimeout(() => {
this.loading = false;
}, 300);
},
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();
this.focusedItemIndex = -1;
}
}); });
},
filterContent(query) {
query = query.toLowerCase().trim();
let anyResults = false;
let visibleCount = 0;
document.querySelectorAll('.article-item').forEach((item) => {
const title = (item.getAttribute('data-title') || '').toLowerCase();
const tags = (item.getAttribute('data-tags') || '').toLowerCase();
const description = (item.getAttribute('data-description') || '').toLowerCase();
const isMatch = query === '' ||
title.includes(query) ||
tags.includes(query) ||
description.includes(query);
if (isMatch) {
item.style.display = '';
anyResults = true;
visibleCount++;
} else {
item.style.display = 'none';
}
});
this.hasResults = query === '' || anyResults;
this.visibleCount = visibleCount;
}
}));
// Register searchProjects component
Alpine.data('searchProjects', () => ({
searchQuery: '',
hasResults: true,
visibleCount: 0,
loading: false,
focusedItemIndex: -1,
debounceTimeout: null,
init() {
// Initialize with loading state
this.loading = true;
// Initialize the visible count
this.visibleCount = document.querySelectorAll('.project-item').length;
// Setup watchers
this.$watch('searchQuery', (query) => {
// Debounce search for better performance
if (this.debounceTimeout) {
clearTimeout(this.debounceTimeout);
}
this.debounceTimeout = setTimeout(() => {
this.filterContent(query);
}, 150);
});
// Setup keyboard shortcuts
this.setupKeyboardShortcuts();
// Set loading to false after initialization
setTimeout(() => {
this.loading = false;
}, 300);
},
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();
this.focusedItemIndex = -1;
}
});
},
filterContent(query) {
query = query.toLowerCase().trim();
let anyResults = false;
let visibleCount = 0;
document.querySelectorAll('.project-item').forEach((item) => {
const title = (item.getAttribute('data-title') || '').toLowerCase();
const tags = (item.getAttribute('data-tags') || '').toLowerCase();
const description = (item.getAttribute('data-description') || '').toLowerCase();
const github = (item.getAttribute('data-github') || '').toLowerCase();
const live = (item.getAttribute('data-live') || '').toLowerCase();
const isMatch = query === '' ||
title.includes(query) ||
tags.includes(query) ||
description.includes(query) ||
github.includes(query) ||
live.includes(query);
if (isMatch) {
item.style.display = '';
anyResults = true;
visibleCount++;
} else {
item.style.display = 'none';
}
});
this.hasResults = query === '' || anyResults;
this.visibleCount = visibleCount;
}
}));
}
}); });
</script> </script>

View File

@ -1,66 +1,56 @@
<script> <script is:inline>
// This script extends the existing Alpine.js functionality for style controls // This script adds keyboard shortcuts for style controls
// by adding keyboard shortcuts document.addEventListener('DOMContentLoaded', () => {
// 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 // Add keyboard shortcut listener
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
// Only process if not in an input field // Only process if not in an input field
const target = e.target as HTMLElement; const target = e.target;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) { if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) {
return; return;
} }
// Find Alpine components that might have style controls
const styleControlsElements = document.querySelectorAll('[x-data="styleControls"]');
const searchServicesElements = document.querySelectorAll('[x-data="searchServices"]');
// Function to find a component with the method we need
const findComponentWithMethod = (elements, methodName) => {
for (const el of elements) {
if (el && el.__x && el.__x.$data && typeof el.__x.$data[methodName] === 'function') {
return el.__x.$data;
}
}
return null;
};
// Try to find components with the methods we need
const styleComponent = findComponentWithMethod(styleControlsElements, 'setIconSize') ||
findComponentWithMethod(searchServicesElements, 'setIconSize');
if (styleComponent) {
// Alt+1, Alt+2, Alt+3 for icon sizes // Alt+1, Alt+2, Alt+3 for icon sizes
if (e.altKey && e.key === '1' && this.setIconSize) { if (e.altKey && e.key === '1' && styleComponent.setIconSize) {
this.setIconSize('small'); styleComponent.setIconSize('small');
} else if (e.altKey && e.key === '2' && this.setIconSize) { } else if (e.altKey && e.key === '2' && styleComponent.setIconSize) {
this.setIconSize('medium'); styleComponent.setIconSize('medium');
} else if (e.altKey && e.key === '3' && this.setIconSize) { } else if (e.altKey && e.key === '3' && styleComponent.setIconSize) {
this.setIconSize('large'); styleComponent.setIconSize('large');
} }
// Alt+G to toggle grid/list view // Alt+G to toggle grid/list view
if (e.altKey && e.key === 'g' && this.toggleViewMode) { if (e.altKey && e.key === 'g' && styleComponent.toggleViewMode) {
this.toggleViewMode(); styleComponent.toggleViewMode();
} }
// Alt+B, Alt+I, Alt+N for display modes // Alt+B, Alt+I, Alt+N for display modes
if (e.altKey && e.key === 'b' && this.setDisplayMode) { if (e.altKey && e.key === 'b' && styleComponent.setDisplayMode) {
this.setDisplayMode('both'); styleComponent.setDisplayMode('both');
} else if (e.altKey && e.key === 'i' && this.setDisplayMode) { } else if (e.altKey && e.key === 'i' && styleComponent.setDisplayMode) {
this.setDisplayMode('image'); styleComponent.setDisplayMode('image');
} else if (e.altKey && e.key === 'n' && this.setDisplayMode) { } else if (e.altKey && e.key === 'n' && styleComponent.setDisplayMode) {
this.setDisplayMode('name'); styleComponent.setDisplayMode('name');
} }
});
} }
};
}); });
}); });
</script> </script>

View File

@ -17,58 +17,47 @@ const {
tag: Tag = 'div' tag: Tag = 'div'
} = Astro.props; } = Astro.props;
const animationClasses = { // Validate animation type
'fade': 'animate-fade', const validAnimations = ['fade', 'scale', 'slide-up', 'slide-down', 'slide-left', 'slide-right', 'pulse'];
'scale': 'animate-scale', const animationClass = validAnimations.includes(animation) ? `animate-${animation}` : '';
'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]}> <Tag class:list={[animationClass, className]}>
<style define:vars={{ <div
animationDuration: `${duration}ms`, class="animation-container"
animationDelay: `${delay}ms`, style={`--animation-duration: ${duration}ms; --animation-delay: ${delay}ms; --animation-easing: ${easing};`}
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 /> <slot />
</div>
</Tag> </Tag>
<style> <style>
/* Animation container to apply styles */
.animation-container {
display: contents;
}
/* Base animation properties */
[class^="animate-"] .animation-container {
animation-duration: var(--animation-duration);
animation-timing-function: var(--animation-easing);
animation-delay: var(--animation-delay);
animation-fill-mode: both;
}
/* Animation types */
.animate-fade { animation-name: fade; }
.animate-scale { animation-name: scale; }
.animate-slide-up { animation-name: slideUp; }
.animate-slide-down { animation-name: slideDown; }
.animate-slide-left { animation-name: slideLeft; }
.animate-slide-right { animation-name: slideRight; }
.animate-pulse {
animation-name: pulse;
animation-iteration-count: infinite;
}
/* Keyframes definitions */
@keyframes fade { @keyframes fade {
from { opacity: 0; } from { opacity: 0; }
to { opacity: 1; } to { opacity: 1; }
@ -105,10 +94,9 @@ const animationClass = animationClasses[animation] || '';
100% { transform: scale(1); } 100% { transform: scale(1); }
} }
/* Respect reduced motion preferences */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.animate-fade, .animate-scale, .animate-slide-up, [class^="animate-"] {
.animate-slide-down, .animate-slide-left,
.animate-slide-right, .animate-pulse {
animation: none !important; animation: none !important;
transition: none !important; transition: none !important;
} }

View File

@ -13,41 +13,71 @@ const {
class: className = '', class: className = '',
} = Astro.props; } = Astro.props;
const sizeMap = { // Map sizes to CSS classes
small: 'w-4 h-4', const sizeClasses = {
medium: 'w-8 h-8', small: 'size-sm',
large: 'w-12 h-12', medium: 'size-md',
large: 'size-lg'
}; };
const sizeClass = sizeMap[size] || sizeMap.medium; const sizeClass = sizeClasses[size] || sizeClasses.medium;
--- ---
<div
class:list={['loading-indicator', `loading-${type}`, sizeClass, className]}
style={`--indicator-color: ${color};`}
role="status"
aria-label="Loading"
>
{type === 'spinner' && ( {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 class="spinner-ring"></div>
<div class="spinner-ring"></div> <div class="spinner-ring"></div>
</div> </>
)} )}
{type === 'dots' && ( {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 class="dot"></div>
<div class="dot"></div> <div class="dot"></div>
</div> </>
)} )}
{type === 'pulse' && ( {type === 'pulse' && (
<div class:list={['loading-pulse', sizeClass, className]} style={{ '--pulse-color': color }}> <div class="pulse-element"></div>
</div>
)} )}
</div>
<style> <style>
/* Spinner animation */ /* Base styles */
.loading-indicator {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--indicator-color, currentColor);
}
/* Size variations */
.size-sm {
width: 1rem;
height: 1rem;
}
.size-md {
width: 2rem;
height: 2rem;
}
.size-lg {
width: 3rem;
height: 3rem;
}
/* Spinner type */
.loading-spinner { .loading-spinner {
position: relative; position: relative;
display: inline-block;
} }
.spinner-ring { .spinner-ring {
@ -57,7 +87,7 @@ const sizeClass = sizeMap[size] || sizeMap.medium;
width: 100%; width: 100%;
height: 100%; height: 100%;
border: 2px solid transparent; border: 2px solid transparent;
border-top-color: var(--spinner-color, currentColor); border-top-color: var(--indicator-color, currentColor);
border-radius: 50%; border-radius: 50%;
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
} }
@ -75,18 +105,15 @@ const sizeClass = sizeMap[size] || sizeMap.medium;
100% { transform: rotate(360deg); } 100% { transform: rotate(360deg); }
} }
/* Dots animation */ /* Dots type */
.loading-dots { .loading-dots {
display: flex;
align-items: center;
justify-content: center;
gap: 0.25rem; gap: 0.25rem;
} }
.loading-dots .dot { .loading-dots .dot {
width: 25%; width: 25%;
height: 25%; height: 25%;
background-color: var(--dots-color, currentColor); background-color: var(--indicator-color, currentColor);
border-radius: 50%; border-radius: 50%;
animation: dotBounce 1.4s infinite ease-in-out both; animation: dotBounce 1.4s infinite ease-in-out both;
} }
@ -104,9 +131,15 @@ const sizeClass = sizeMap[size] || sizeMap.medium;
40% { transform: scale(1); } 40% { transform: scale(1); }
} }
/* Pulse animation */ /* Pulse type */
.loading-pulse { .loading-pulse {
background-color: var(--pulse-color, currentColor); overflow: hidden;
}
.loading-pulse .pulse-element {
width: 100%;
height: 100%;
background-color: var(--indicator-color, currentColor);
border-radius: 50%; border-radius: 50%;
animation: pulse 1.5s ease-in-out infinite; animation: pulse 1.5s ease-in-out infinite;
} }
@ -119,8 +152,12 @@ const sizeClass = sizeMap[size] || sizeMap.medium;
/* Respect reduced motion preferences */ /* Respect reduced motion preferences */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.spinner-ring, .loading-dots .dot, .loading-pulse { .spinner-ring, .dot, .pulse-element {
animation: none; animation: none !important;
}
.pulse-element {
opacity: 0.7;
} }
} }
</style> </style>

View File

@ -18,6 +18,8 @@ const {
class={`loading-overlay fixed inset-0 flex flex-col items-center justify-center z-50 bg-opacity-90 zag-bg ${className}`} class={`loading-overlay fixed inset-0 flex flex-col items-center justify-center z-50 bg-opacity-90 zag-bg ${className}`}
role="alert" role="alert"
aria-live="polite" aria-live="polite"
aria-atomic="true"
data-loading-overlay
> >
<div class="loading-content text-center p-8 rounded-lg"> <div class="loading-content text-center p-8 rounded-lg">
<!-- Enhanced loading indicator --> <!-- Enhanced loading indicator -->
@ -73,15 +75,87 @@ const {
</style> </style>
<script> <script>
// This script will be executed client-side // Enhanced loading manager with Intersection Observer and performance optimizations
class LoadingManager { class LoadingManager {
overlay: HTMLElement | null = null; overlay: HTMLElement | null = null;
timeoutId: ReturnType<typeof setTimeout> | null = null; timeoutId: ReturnType<typeof setTimeout> | null = null;
longLoadingTimeoutId: ReturnType<typeof setTimeout> | null = null; longLoadingTimeoutId: ReturnType<typeof setTimeout> | null = null;
loadingElements: Set<Element> = new Set();
observer: IntersectionObserver | null = null;
constructor() { constructor() {
this.overlay = document.querySelector('.loading-overlay'); this.overlay = document.querySelector('[data-loading-overlay]');
this.setupNavigationListeners(); this.setupNavigationListeners();
this.setupIntersectionObserver();
this.observeLoadingElements();
}
setupIntersectionObserver() {
// Create an intersection observer to detect when loading elements are in the viewport
this.observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// Element is in viewport, add to loading elements set
this.loadingElements.add(entry.target);
this.updateLoadingState();
} else {
// Element is out of viewport, remove from loading elements set
this.loadingElements.delete(entry.target);
this.updateLoadingState();
}
});
},
{
threshold: 0.1, // 10% visibility threshold
rootMargin: '100px' // Start loading slightly before elements come into view
}
);
}
observeLoadingElements() {
// Observe all elements with loading-element class
document.querySelectorAll('[data-loading="true"]').forEach(el => {
if (this.observer) {
this.observer.observe(el);
}
});
// Set up a mutation observer to detect new loading elements
const mutationObserver = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
if (mutation.type === 'attributes' && mutation.attributeName === 'data-loading') {
const target = mutation.target as Element;
if (target.getAttribute('data-loading') === 'true') {
if (this.observer) {
this.observer.observe(target);
}
} else {
if (this.observer) {
this.observer.unobserve(target);
}
this.loadingElements.delete(target);
this.updateLoadingState();
}
}
});
});
// Observe the entire document for changes to data-loading attribute
mutationObserver.observe(document.body, {
attributes: true,
attributeFilter: ['data-loading'],
subtree: true
});
}
updateLoadingState() {
// If there are loading elements in the viewport, show loading state
if (this.loadingElements.size > 0) {
this.showLoading();
} else {
this.hideLoading();
}
} }
setupNavigationListeners() { setupNavigationListeners() {
@ -113,6 +187,8 @@ const {
// For SPA navigation (if using client-side routing) // For SPA navigation (if using client-side routing)
document.addEventListener('astro:page-load', () => { document.addEventListener('astro:page-load', () => {
this.hideLoading(); this.hideLoading();
// Re-observe loading elements after page load
this.observeLoadingElements();
}); });
} }
@ -123,10 +199,15 @@ const {
document.addEventListener('astro:after-swap', () => { document.addEventListener('astro:after-swap', () => {
this.hideLoading(); this.hideLoading();
// Re-observe loading elements after view transition
this.observeLoadingElements();
}); });
} }
showLoading() { showLoading() {
// Use requestIdleCallback for non-critical UI updates if available
const scheduleUpdate = window.requestIdleCallback || window.requestAnimationFrame || setTimeout;
// Clear any existing timeouts // Clear any existing timeouts
if (this.timeoutId) { if (this.timeoutId) {
clearTimeout(this.timeoutId); clearTimeout(this.timeoutId);
@ -143,6 +224,7 @@ const {
// Only show loading UI after a short delay to avoid flashing on fast loads // Only show loading UI after a short delay to avoid flashing on fast loads
this.timeoutId = setTimeout(() => { this.timeoutId = setTimeout(() => {
scheduleUpdate(() => {
// Set loading state in Alpine.js components if they exist // Set loading state in Alpine.js components if they exist
document.querySelectorAll('[x-data]').forEach(el => { document.querySelectorAll('[x-data]').forEach(el => {
// @ts-ignore // @ts-ignore
@ -151,6 +233,7 @@ const {
el.__x.$data.loading = true; el.__x.$data.loading = true;
} }
}); });
});
}, showDelay); }, showDelay);
// Show the overlay for long loading times (5+ seconds) // Show the overlay for long loading times (5+ seconds)
@ -216,10 +299,35 @@ const {
// Default delay if Network Information API is not available // Default delay if Network Information API is not available
return 100; return 100;
} }
// Clean up resources when the component is destroyed
destroy() {
if (this.observer) {
this.observer.disconnect();
}
if (this.timeoutId) {
clearTimeout(this.timeoutId);
}
if (this.longLoadingTimeoutId) {
clearTimeout(this.longLoadingTimeoutId);
}
}
} }
// Initialize the loading manager when the DOM is ready // Initialize the loading manager when the DOM is ready
let loadingManager: LoadingManager | null = null;
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
new LoadingManager(); loadingManager = new LoadingManager();
});
// Clean up on page unload
window.addEventListener('beforeunload', () => {
if (loadingManager) {
loadingManager.destroy();
loadingManager = null;
}
}); });
</script> </script>

View File

@ -3,103 +3,92 @@ import { Image } from 'astro:assets';
import type { ImageMetadata } from 'astro'; import type { ImageMetadata } from 'astro';
/** /**
* ResponsiveImage component with advanced features for optimal performance * ResponsiveImage component provides enhanced image handling with:
* - Art direction support via <picture> element
* - Modern format support (AVIF, WebP)
* - Blur-up loading effect
* - Lazy loading with IntersectionObserver
* - Priority hints for important images
*
* @component * @component
* @example
* ```astro
* <ResponsiveImage
* src={import('../assets/hero.jpg')}
* alt="Hero image"
* width={1200}
* height={600}
* priority={true}
* />
* ```
*/ */
interface Props { interface Props {
/** /** The image source (either imported via astro:assets or a URL string) */
* The image source (either an imported image or a URL)
*/
src: ImageMetadata | string; src: ImageMetadata | string;
/** /** Alternative text for the image (required for accessibility) */
* Alternative text for the image
*/
alt: string; alt: string;
/** /** Width of the image in pixels */
* Base width of the image
*/
width?: number; width?: number;
/** /** Height of the image in pixels */
* Base height of the image
*/
height?: number; height?: number;
/** /** Additional CSS classes to apply to the container */
* CSS class to apply to the image
*/
class?: string; class?: string;
/** /** Responsive sizes attribute (e.g. "(max-width: 768px) 100vw, 768px") */
* Sizes attribute for responsive images
* @default "(min-width: 1280px) 1280px, (min-width: 1024px) 1024px, (min-width: 768px) 768px, 100vw"
*/
sizes?: string; sizes?: string;
/** /** Loading strategy ('lazy' or 'eager') */
* Loading strategy
* @default "lazy"
*/
loading?: 'eager' | 'lazy'; loading?: 'eager' | 'lazy';
/** /** Decoding strategy */
* Decoding strategy
* @default "async"
*/
decoding?: 'sync' | 'async' | 'auto'; decoding?: 'sync' | 'async' | 'auto';
/** /** Image format to convert to */
* Image format
* @default "auto"
*/
format?: 'webp' | 'avif' | 'png' | 'jpeg' | 'jpg' | 'auto'; format?: 'webp' | 'avif' | 'png' | 'jpeg' | 'jpg' | 'auto';
/** /** Image quality (1-100) */
* Image quality (1-100)
* @default 80
*/
quality?: number; quality?: number;
/** /** Whether to apply a blur-up loading effect */
* Whether to add a blur-up effect
* @default false
*/
blurUp?: boolean; blurUp?: boolean;
/** /** Whether this is a high-priority image (sets fetchpriority="high" and loading="eager") */
* Whether this is a priority image (above the fold)
* @default false
*/
priority?: boolean; priority?: boolean;
/** /** Breakpoints for responsive images */
* Breakpoints for responsive images
* @default [640, 768, 1024, 1280]
*/
breakpoints?: number[]; breakpoints?: number[];
/** /** Whether to use art direction with different image sources for different screen sizes */
* Whether to use art direction (different images for different breakpoints)
* @default false
*/
artDirected?: boolean; artDirected?: boolean;
/** /** Source for mobile screens (max-width: 640px) */
* Mobile image source (for art direction)
*/
mobileSrc?: ImageMetadata | string; mobileSrc?: ImageMetadata | string;
/** /** Source for tablet screens (641px to 1023px) */
* Tablet image source (for art direction)
*/
tabletSrc?: ImageMetadata | string; tabletSrc?: ImageMetadata | string;
/** /** Source for desktop screens (min-width: 1024px) */
* Desktop image source (for art direction)
*/
desktopSrc?: ImageMetadata | string; desktopSrc?: ImageMetadata | string;
/** Additional sources for art-directed images with custom media queries */
additionalSources?: Array<{
media: string;
src: ImageMetadata | string;
type?: string;
}>;
/** Whether to add a container div around the image */
container?: boolean;
/** Aspect ratio to maintain (e.g. "16:9", "4:3", "1:1") */
aspectRatio?: string;
/** Whether to use native lazy loading */
nativeLazy?: boolean;
} }
const { const {
@ -119,41 +108,141 @@ const {
artDirected = false, artDirected = false,
mobileSrc, mobileSrc,
tabletSrc, tabletSrc,
desktopSrc desktopSrc,
additionalSources = [],
container = true,
aspectRatio,
nativeLazy = true
} = Astro.props; } = Astro.props;
// Determine loading strategy based on priority // Determine loading strategy based on priority
const loading = priority ? 'eager' : propLoading || 'lazy'; const loading = priority ? 'eager' : propLoading || 'lazy';
// Generate a unique ID for this image instance // Set fetchpriority based on priority
const imageId = `img-${Math.random().toString(36).substring(2, 11)}`; const fetchPriority = priority ? 'high' : 'auto';
// Generate a unique ID using crypto for better randomness
const imageId = `img-${crypto.randomUUID().slice(0, 8)}`;
// Determine if we're using a string URL or an imported image // Determine if we're using a string URL or an imported image
const isStringSource = typeof src === 'string'; const isStringSource = typeof src === 'string';
// Placeholder for blur-up effect (simplified version) // Simplified placeholder style
const placeholderStyle = blurUp ? 'filter: blur(20px); transition: filter 0.5s ease-out;' : ''; const placeholderClass = blurUp ? 'blur-up' : '';
// Calculate aspect ratio styles if provided
let aspectRatioStyle = '';
if (aspectRatio) {
const [width, height] = aspectRatio.split(':').map(Number);
if (width && height) {
const paddingBottom = (height / width) * 100;
aspectRatioStyle = `--aspect-ratio: ${paddingBottom}%;`;
}
}
// Container class based on aspect ratio
const containerClass = aspectRatio ? 'responsive-container aspect-ratio' : 'responsive-container';
--- ---
{artDirected ? ( {artDirected ? (
<picture> <picture class={`responsive-picture ${className}`} style={aspectRatioStyle}>
{/* AVIF format sources if not using string sources */}
{!isStringSource && format === 'auto' && (
<>
{/* Mobile AVIF */}
{mobileSrc && typeof mobileSrc !== 'string' && (
<source
type="image/avif"
media="(max-width: 640px)"
srcset={`${mobileSrc.src}?w=${width || mobileSrc.width}&format=avif&q=${quality}`}
/>
)}
{/* Tablet AVIF */}
{tabletSrc && typeof tabletSrc !== 'string' && (
<source
type="image/avif"
media="(min-width: 641px) and (max-width: 1023px)"
srcset={`${tabletSrc.src}?w=${width || tabletSrc.width}&format=avif&q=${quality}`}
/>
)}
{/* Desktop AVIF */}
{desktopSrc && typeof desktopSrc !== 'string' && (
<source
type="image/avif"
media="(min-width: 1024px)"
srcset={`${desktopSrc.src}?w=${width || desktopSrc.width}&format=avif&q=${quality}`}
/>
)}
</>
)}
{/* WebP format sources if not using string sources */}
{!isStringSource && format === 'auto' && (
<>
{/* Mobile WebP */}
{mobileSrc && typeof mobileSrc !== 'string' && (
<source
type="image/webp"
media="(max-width: 640px)"
srcset={`${mobileSrc.src}?w=${width || mobileSrc.width}&format=webp&q=${quality}`}
/>
)}
{/* Tablet WebP */}
{tabletSrc && typeof tabletSrc !== 'string' && (
<source
type="image/webp"
media="(min-width: 641px) and (max-width: 1023px)"
srcset={`${tabletSrc.src}?w=${width || tabletSrc.width}&format=webp&q=${quality}`}
/>
)}
{/* Desktop WebP */}
{desktopSrc && typeof desktopSrc !== 'string' && (
<source
type="image/webp"
media="(min-width: 1024px)"
srcset={`${desktopSrc.src}?w=${width || desktopSrc.width}&format=webp&q=${quality}`}
/>
)}
</>
)}
{/* Original format sources */}
{/* Mobile image */} {/* Mobile image */}
{mobileSrc && (
<source <source
media="(max-width: 640px)" media="(max-width: 640px)"
srcset={typeof mobileSrc === 'string' ? mobileSrc : typeof src === 'string' ? src : ''} srcset={typeof mobileSrc === 'string' ? mobileSrc : ''}
/> />
)}
{/* Tablet image */} {/* Tablet image */}
{tabletSrc && (
<source <source
media="(min-width: 641px) and (max-width: 1023px)" media="(min-width: 641px) and (max-width: 1023px)"
srcset={typeof tabletSrc === 'string' ? tabletSrc : typeof src === 'string' ? src : ''} srcset={typeof tabletSrc === 'string' ? tabletSrc : ''}
/> />
)}
{/* Desktop image */} {/* Desktop image */}
{desktopSrc && (
<source <source
media="(min-width: 1024px)" media="(min-width: 1024px)"
srcset={typeof desktopSrc === 'string' ? desktopSrc : typeof src === 'string' ? src : ''} srcset={typeof desktopSrc === 'string' ? desktopSrc : ''}
/> />
)}
{/* Additional custom sources */}
{additionalSources.map(source => (
<source
media={source.media}
srcset={typeof source.src === 'string' ? source.src : ''}
type={source.type}
/>
))}
{/* Fallback image */} {/* Fallback image */}
{isStringSource ? ( {isStringSource ? (
@ -162,11 +251,12 @@ const placeholderStyle = blurUp ? 'filter: blur(20px); transition: filter 0.5s e
alt={alt} alt={alt}
width={width} width={width}
height={height} height={height}
class={className} class={`responsive-img ${placeholderClass}`}
loading={loading} loading={nativeLazy ? loading : undefined}
decoding={decoding} decoding={decoding}
fetchpriority={fetchPriority}
id={imageId} id={imageId}
style={placeholderStyle} data-loading={!nativeLazy && loading === 'lazy' ? 'true' : undefined}
/> />
) : ( ) : (
<Image <Image
@ -174,28 +264,66 @@ const placeholderStyle = blurUp ? 'filter: blur(20px); transition: filter 0.5s e
alt={alt} alt={alt}
width={width} width={width}
height={height} height={height}
class={className} class={`responsive-img ${placeholderClass}`}
sizes={sizes} sizes={sizes}
loading={loading} loading={nativeLazy ? loading : undefined}
decoding={decoding} decoding={decoding}
quality={quality} quality={quality}
fetchpriority={fetchPriority}
id={imageId} id={imageId}
data-loading={!nativeLazy && loading === 'lazy' ? 'true' : undefined}
/> />
)} )}
</picture> </picture>
) : ( ) : (
/* Standard responsive image */ /* Standard responsive image */
container ? (
<div class={`${containerClass} ${className}`} style={aspectRatioStyle}>
{isStringSource ? (
<img
src={src}
alt={alt}
width={width}
height={height}
class={`responsive-img ${placeholderClass}`}
loading={nativeLazy ? loading : undefined}
decoding={decoding}
fetchpriority={fetchPriority}
id={imageId}
data-loading={!nativeLazy && loading === 'lazy' ? 'true' : undefined}
/>
) : (
<Image
src={src}
alt={alt}
width={width}
height={height}
class={`responsive-img ${placeholderClass}`}
sizes={sizes}
loading={nativeLazy ? loading : undefined}
decoding={decoding}
format={format === 'auto' ? undefined : format}
quality={quality}
fetchpriority={fetchPriority}
id={imageId}
data-loading={!nativeLazy && loading === 'lazy' ? 'true' : undefined}
/>
)}
</div>
) : (
/* No container */
isStringSource ? ( isStringSource ? (
<img <img
src={src} src={src}
alt={alt} alt={alt}
width={width} width={width}
height={height} height={height}
class={className} class={`responsive-img ${placeholderClass} ${className}`}
loading={loading} loading={nativeLazy ? loading : undefined}
decoding={decoding} decoding={decoding}
fetchpriority={fetchPriority}
id={imageId} id={imageId}
style={placeholderStyle} data-loading={!nativeLazy && loading === 'lazy' ? 'true' : undefined}
/> />
) : ( ) : (
<Image <Image
@ -203,41 +331,132 @@ const placeholderStyle = blurUp ? 'filter: blur(20px); transition: filter 0.5s e
alt={alt} alt={alt}
width={width} width={width}
height={height} height={height}
class={className} class={`responsive-img ${placeholderClass} ${className}`}
sizes={sizes} sizes={sizes}
loading={loading} loading={nativeLazy ? loading : undefined}
decoding={decoding} decoding={decoding}
format={format === 'auto' ? undefined : format} format={format === 'auto' ? undefined : format}
quality={quality} quality={quality}
fetchpriority={fetchPriority}
id={imageId} id={imageId}
data-loading={!nativeLazy && loading === 'lazy' ? 'true' : undefined}
/> />
) )
)
)} )}
{blurUp && ( {(blurUp || !nativeLazy) && (
<script define:vars={{ imageId }}> <script define:vars={{ imageId, blurUp, nativeLazy, loading }}>
// Simple blur-up effect // Enhanced image loading with IntersectionObserver
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const img = document.getElementById(imageId); const img = document.getElementById(imageId);
if (img) { if (!img) return;
img.onload = () => {
img.style.filter = 'blur(0)'; // Function to handle image load completion
}; const handleImageLoaded = () => {
if (blurUp) {
img.classList.add('loaded');
} }
// Dispatch a custom event that other components can listen for
img.dispatchEvent(new CustomEvent('imageLoaded', {
bubbles: true,
detail: { imageId }
}));
};
// Use IntersectionObserver for both blur-up effect and custom lazy loading
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// Set up the onload handler
img.onload = handleImageLoaded;
// If not using native lazy loading and this is a lazy image,
// we need to set the src attribute now
if (!nativeLazy && loading === 'lazy') {
const dataSrc = img.getAttribute('data-src');
if (dataSrc) {
img.src = dataSrc;
}
}
// If image is already loaded, handle it immediately
if (img.complete) {
handleImageLoaded();
}
// Stop observing once we've handled this image
observer.disconnect();
}
});
}, {
// Use a small rootMargin to start loading slightly before the image is visible
rootMargin: '200px',
threshold: 0.01
});
observer.observe(img);
}); });
</script> </script>
)} )}
<style> <style define:vars={{ aspectRatioStyle }}>
/* Prevent layout shifts by maintaining aspect ratio */ /* Base container styles */
img { .responsive-container {
display: block;
width: 100%;
position: relative;
overflow: hidden;
}
/* Aspect ratio container */
.aspect-ratio {
position: relative;
padding-bottom: var(--aspect-ratio, 56.25%); /* Default to 16:9 if not specified */
}
.aspect-ratio .responsive-img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
/* Picture element styles */
.responsive-picture {
display: block;
width: 100%;
position: relative;
}
/* Base image styles */
.responsive-img {
display: block; display: block;
max-width: 100%; max-width: 100%;
height: auto; height: auto;
width: 100%;
}
/* Improved blur-up effect with smoother transition */
.blur-up {
filter: blur(20px);
transform: scale(1.05);
transition:
filter 0.5s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.5s cubic-bezier(0.4, 0, 0.2, 1);
will-change: filter, transform;
}
.blur-up.loaded {
filter: blur(0);
transform: scale(1);
} }
/* Add subtle loading animation for lazy-loaded images */ /* Add subtle loading animation for lazy-loaded images */
img:not([loading="eager"]) { img:not([loading="eager"]):not(.blur-up) {
animation: fadeIn 0.5s ease-in-out; animation: fadeIn 0.5s ease-in-out;
} }
@ -245,4 +464,35 @@ const placeholderStyle = blurUp ? 'filter: blur(20px); transition: filter 0.5s e
from { opacity: 0; } from { opacity: 0; }
to { opacity: 1; } to { opacity: 1; }
} }
/* Reduced motion preference */
@media (prefers-reduced-motion: reduce) {
.blur-up {
transition: filter 0.1s ease-out;
transform: none;
}
img:not([loading="eager"]):not(.blur-up) {
animation: none;
}
}
/* Container query support for responsive sizing within components */
@supports (container-type: inline-size) {
.responsive-container {
container-type: inline-size;
}
@container (max-width: 400px) {
.responsive-img {
border-radius: 0.25rem;
}
}
@container (min-width: 401px) {
.responsive-img {
border-radius: 0.5rem;
}
}
}
</style> </style>

View File

@ -19,13 +19,13 @@ const {
class: className = '', class: className = '',
} = Astro.props; } = Astro.props;
const id = `scroll-reveal-${Math.random().toString(36).substring(2, 9)}`; // Generate a unique ID with better entropy
const id = `scroll-reveal-${crypto.randomUUID().slice(0, 8)}`;
--- ---
<div <div
id={id} id={id}
class:list={['scroll-reveal', className]} class:list={['scroll-reveal', `reveal-${animation}`, className]}
data-animation={animation}
data-duration={duration} data-duration={duration}
data-delay={delay} data-delay={delay}
data-threshold={threshold} data-threshold={threshold}
@ -39,32 +39,18 @@ const id = `scroll-reveal-${Math.random().toString(36).substring(2, 9)}`;
.scroll-reveal { .scroll-reveal {
opacity: 0; opacity: 0;
will-change: transform, opacity; will-change: transform, opacity;
transition-property: opacity, transform;
} }
.scroll-reveal[data-animation="fade-up"] { /* Initial states based on animation type */
transform: translateY(30px); .reveal-fade-up { transform: translateY(30px); }
} .reveal-fade-down { transform: translateY(-30px); }
.reveal-fade-left { transform: translateX(30px); }
.scroll-reveal[data-animation="fade-down"] { .reveal-fade-right { transform: translateX(-30px); }
transform: translateY(-30px); .reveal-zoom-in { transform: scale(0.9); }
} .reveal-zoom-out { transform: scale(1.1); }
.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);
}
/* Revealed state */
.scroll-reveal.revealed { .scroll-reveal.revealed {
opacity: 1; opacity: 1;
transform: translate(0) scale(1); transform: translate(0) scale(1);

View File

@ -35,26 +35,33 @@ interface Props {
} }
const { name, href, img, alt } = Astro.props; const { name, href, img, alt } = Astro.props;
// Generate a unique ID for the service card
const cardId = `service-card-${crypto.randomUUID().slice(0, 8)}`;
--- ---
<a <a
id={cardId}
href={href} href={href}
class="service-card zag-interactive flex items-center transition-all duration-300" class="service-card zag-interactive flex items-center"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
aria-label={`Open ${name} in a new tab`} aria-label={`Open ${name} in a new tab`}
> >
<div class="service-icon-container flex-shrink-0 relative"> <div class="service-icon-container flex-shrink-0 relative">
<div class="service-icon-background absolute inset-0 rounded-full opacity-0 transition-all duration-300"></div> <div class="service-icon-background"></div>
<img <img
src={img} src={img}
alt={alt} alt={alt}
class="service-icon w-16 h-16 transition-all duration-300 relative z-10" class="service-icon"
loading="lazy" loading="lazy"
decoding="async" decoding="async"
width="64"
height="64"
fetchpriority="low"
/> />
</div> </div>
<p class="service-name mt-2 text-center transition-all duration-300">{name}</p> <p class="service-name">{name}</p>
<!-- QR code for print view only --> <!-- QR code for print view only -->
<div class="print-qr-code"> <div class="print-qr-code">
@ -64,18 +71,138 @@ const { name, href, img, alt } = Astro.props;
</a> </a>
<style> <style>
/* Default (grid) view */ /* Base card styles with CSS custom properties */
.service-card { .service-card {
--card-transition-duration: 0.3s;
--card-transition-timing: cubic-bezier(0.25, 0.8, 0.25, 1);
--card-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
--card-hover-shadow: 0 10px 20px rgba(0, 0, 0, 0.15);
--card-active-shadow: 0 5px 10px rgba(0, 0, 0, 0.1);
--card-border-radius: 0.5rem;
--card-padding: 0.5rem;
--icon-size: 4rem;
/* Layout */
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
/* Appearance */
box-shadow: var(--card-shadow);
border: 2px solid transparent;
background-color: var(--color-zag-bg);
border-radius: var(--card-border-radius);
overflow: hidden;
position: relative;
/* Transitions */
transition:
transform var(--card-transition-duration) var(--card-transition-timing),
box-shadow var(--card-transition-duration) var(--card-transition-timing),
border-color var(--card-transition-duration) var(--card-transition-timing),
background-color var(--card-transition-duration) var(--card-transition-timing);
/* Performance optimizations */
will-change: transform, box-shadow, border-color;
} }
/* List view adjustments applied via JS */ /* Gradient background effect */
.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 var(--card-transition-duration) var(--card-transition-timing);
z-index: -1;
}
/* Icon container */
.service-icon-container {
position: relative;
transition: transform var(--card-transition-duration) var(--card-transition-timing);
flex-shrink: 0;
}
/* Icon background glow */
.service-icon-background {
position: absolute;
inset: 0;
background: radial-gradient(circle, var(--color-zag-accent) 0%, transparent 70%);
border-radius: 50%;
opacity: 0;
transform: scale(0.8);
transition:
opacity var(--card-transition-duration) var(--card-transition-timing),
transform var(--card-transition-duration) var(--card-transition-timing);
}
/* Icon image */
.service-icon {
width: var(--icon-size);
height: var(--icon-size);
position: relative;
z-index: 10;
transition:
transform var(--card-transition-duration) var(--card-transition-timing),
filter var(--card-transition-duration) var(--card-transition-timing);
}
/* Service name */
.service-name {
margin-top: 0.5rem;
text-align: center;
transition:
transform var(--card-transition-duration) var(--card-transition-timing),
font-weight var(--card-transition-duration) var(--card-transition-timing);
}
/* Hover effects */
.service-card:hover {
transform: translateY(-4px) scale(1.02);
box-shadow: var(--card-hover-shadow);
border-color: var(--color-zag-accent);
background-color: var(--color-zag-bg-hover);
z-index: 10;
}
.service-card:hover::before {
opacity: 0.15;
}
.service-card:hover .service-icon-container {
transform: translateY(-2px);
}
.service-card:hover .service-icon {
transform: scale(1.1) rotate(2deg);
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.1));
}
.service-card:hover .service-icon-background {
opacity: 0.2;
transform: scale(1.5);
}
.service-card:hover .service-name {
transform: translateY(2px);
font-weight: 500;
}
/* Active state */
.service-card:active {
transform: translateY(-2px) scale(0.98);
box-shadow: var(--card-active-shadow);
transition: all 0.1s var(--card-transition-timing);
}
/* List view styles */
:global(.view-mode-list) .service-card { :global(.view-mode-list) .service-card {
flex-direction: row; flex-direction: row;
justify-content: flex-start; justify-content: flex-start;
padding: 0.5rem; padding: var(--card-padding);
border-radius: 0.375rem;
} }
:global(.view-mode-list) .service-name { :global(.view-mode-list) .service-name {
@ -84,12 +211,15 @@ const { name, href, img, alt } = Astro.props;
text-align: left; text-align: left;
} }
/* Display mode styles */ :global(.view-mode-list) .service-card:hover {
/* Default display mode (both) */ transform: translateX(4px) scale(1.01);
.service-icon-container, .service-name {
display: block;
} }
:global(.view-mode-list) .service-card:active {
transform: translateX(2px) scale(0.99);
}
/* Display mode styles */
/* Image only mode */ /* Image only mode */
:global(.display-image-only) .service-name { :global(.display-image-only) .service-name {
display: none; display: none;
@ -119,125 +249,31 @@ const { name, href, img, alt } = Astro.props;
:global(.view-mode-list.display-image-only) .service-card { :global(.view-mode-list.display-image-only) .service-card {
justify-content: center; justify-content: center;
padding: 0.5rem; padding: var(--card-padding);
} }
/* Icon size adjustments with CSS variables for fine-grained control */ /* Icon size adjustments */
:global(#app-list) { :global(#app-list) {
--icon-scale: 2; /* Default medium size */ --icon-scale: 2; /* Default medium size */
--icon-base-size: 1rem; --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 { :global(.icon-size-small) .service-icon {
width: 2rem; --icon-size: 2rem;
height: 2rem;
} }
:global(.icon-size-medium) .service-icon { :global(.icon-size-medium) .service-icon {
width: 4rem; --icon-size: 4rem;
height: 4rem;
} }
:global(.icon-size-large) .service-icon { :global(.icon-size-large) .service-icon {
width: 6rem; --icon-size: 6rem;
height: 6rem;
}
/* Enhanced hover effects */
.service-card {
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
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;
will-change: transform, box-shadow, border-color;
}
.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 cubic-bezier(0.25, 0.8, 0.25, 1);
z-index: -1;
}
/* Enhanced micro-interactions */
.service-card:hover {
transform: translateY(-4px) scale(1.02);
box-shadow: 0 10px 20px 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.15;
}
.service-card:active {
transform: translateY(-2px) scale(0.98);
box-shadow: 0 5px 10px rgba(0, 0, 0, 0.1);
transition: all 0.1s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.service-icon-container {
position: relative;
transition: transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.service-icon-background {
background: radial-gradient(circle, var(--color-zag-accent) 0%, transparent 70%);
transform: scale(0.8);
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.service-card:hover .service-icon-container {
transform: translateY(-2px);
}
.service-card:hover .service-icon {
transform: scale(1.1) rotate(2deg);
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.1));
}
.service-card:hover .service-icon-background {
opacity: 0.2;
transform: scale(1.5);
}
.service-card:hover .service-name {
transform: translateY(2px);
font-weight: 500;
}
:global(.view-mode-list) .service-card:hover {
transform: translateX(4px) scale(1.01);
}
:global(.view-mode-list) .service-card:active {
transform: translateX(2px) scale(0.99);
} }
/* Dark mode adjustments */ /* Dark mode adjustments */
:global(.dark) .service-card { :global(.dark) .service-card {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); --card-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
} --card-hover-shadow: 0 10px 20px rgba(0, 0, 0, 0.4);
:global(.dark) .service-card:hover {
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.4);
} }
/* Print-specific styles */ /* Print-specific styles */
@ -304,4 +340,27 @@ const { name, href, img, alt } = Astro.props;
white-space: nowrap; white-space: nowrap;
} }
} }
/* Reduced motion preference */
@media (prefers-reduced-motion: reduce) {
.service-card,
.service-card::before,
.service-icon-container,
.service-icon-background,
.service-icon,
.service-name {
transition: none !important;
animation: none !important;
}
.service-card:hover {
transform: none;
}
.service-card:hover .service-icon-container,
.service-card:hover .service-icon,
.service-card:hover .service-name {
transform: none;
}
}
</style> </style>

View File

@ -18,6 +18,9 @@
* @property {string} projectShortDescription - A short description of the projects * @property {string} projectShortDescription - A short description of the projects
* @property {string} projectLongDescription - A longer description of the projects * @property {string} projectLongDescription - A longer description of the projects
* @property {string} profileImage - The profile image filename * @property {string} profileImage - The profile image filename
* @property {string} githubProfile - The URL to the GitHub profile
* @property {string} linkedinProfile - The URL to the LinkedIn profile
* @property {string} giteaProfile - The URL to the Gitea profile
* @property {Object} menu - The menu items * @property {Object} menu - The menu items
*/ */
export const GLOBAL = { export const GLOBAL = {
@ -49,6 +52,11 @@ export const GLOBAL = {
// Profile image // Profile image
profileImage: "pixel_avatar.png", profileImage: "pixel_avatar.png",
// Social media profiles
githubProfile: "https://github.com/justindeal",
linkedinProfile: "https://www.linkedin.com/in/justin-deal/",
giteaProfile: "https://code.justin.deal/dealjus",
// Menu items // Menu items
menu: { menu: {
home: "/", home: "/",

View File

@ -0,0 +1,257 @@
/**
* Base search module that provides core search functionality
* This module can be extended for specific search implementations
*/
/**
* Initialize search functionality for any content type
* @param {string} contentSelector - CSS selector for searchable items
* @param {Object} options - Configuration options
* @returns {Object} Alpine.js data object with search functionality
*/
export function initializeBaseSearch(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',
debounceTime: 150 // ms to debounce search input
};
const config = { ...defaults, ...options };
return {
searchQuery: '',
hasResults: true,
visibleCount: 0,
loading: false,
focusedItemIndex: -1,
debounceTimeout: null,
init() {
// Initialize the visible count
this.visibleCount = document.querySelectorAll(contentSelector).length;
this.setupWatchers();
this.setupKeyboardShortcuts();
// Handle theme changes
window.addEventListener('theme-changed', () => {
this.filterContent(this.searchQuery);
});
},
setupWatchers() {
this.$watch('searchQuery', (query) => {
// Debounce search for better performance
if (this.debounceTimeout) {
clearTimeout(this.debounceTimeout);
}
this.debounceTimeout = setTimeout(() => {
this.filterContent(query);
}, config.debounceTime);
});
},
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();
this.focusedItemIndex = -1;
this.clearItemFocus();
}
// Arrow key navigation through results
if (this.searchQuery && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
e.preventDefault();
this.handleArrowNavigation(e.key);
}
// Enter key selects the focused item
if (e.key === 'Enter' && this.focusedItemIndex >= 0) {
this.handleEnterSelection();
}
});
},
handleArrowNavigation(key) {
const visibleItems = this.getVisibleItems();
if (visibleItems.length === 0) return;
// Update focused item index
if (key === 'ArrowDown') {
this.focusedItemIndex = Math.min(this.focusedItemIndex + 1, visibleItems.length - 1);
} else {
this.focusedItemIndex = Math.max(this.focusedItemIndex - 1, -1);
}
// Clear previous focus
this.clearItemFocus();
// If we're back at -1, focus the search input
if (this.focusedItemIndex === -1) {
document.getElementById('app-search').focus();
return;
}
// Focus the new item
const itemToFocus = visibleItems[this.focusedItemIndex];
this.focusItem(itemToFocus);
},
handleEnterSelection() {
const visibleItems = this.getVisibleItems();
if (visibleItems.length === 0) return;
const selectedItem = visibleItems[this.focusedItemIndex];
const link = selectedItem.querySelector('a');
if (link) {
link.click();
}
},
getVisibleItems() {
return Array.from(document.querySelectorAll(contentSelector))
.filter(item => item.style.display !== 'none');
},
clearItemFocus() {
// Remove focus styling from all items
document.querySelectorAll(`${contentSelector}.keyboard-focus`).forEach(item => {
item.classList.remove('keyboard-focus');
});
},
focusItem(item) {
// Add focus styling
item.classList.add('keyboard-focus');
// Scroll into view with options for smooth scrolling
const scrollBehavior = window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth';
item.scrollIntoView({ behavior: scrollBehavior, block: 'nearest' });
},
filterContent(query) {
query = query.toLowerCase().trim();
let anyResults = false;
let visibleCount = 0;
// Process all content items
document.querySelectorAll(contentSelector).forEach((item) => {
const isMatch = this.isItemMatch(item, query);
if (isMatch) {
item.style.display = '';
anyResults = true;
visibleCount++;
} else {
item.style.display = 'none';
}
});
// Update category visibility if applicable
this.updateCategoryVisibility(query);
// Update parent containers if needed
this.updateContainerVisibility(query);
this.updateResultsStatus(query, anyResults, visibleCount);
},
isItemMatch(item, query) {
// If query is empty, show all items
if (query === '') {
return true;
}
// 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);
});
// Check if any attribute matches the query
return name.includes(query) ||
tags.includes(query) ||
category.includes(query) ||
additionalMatches;
},
updateCategoryVisibility(query) {
// Only proceed if we have category sections
const categorySections = document.querySelectorAll('.category-section');
if (categorySections.length === 0) return;
// For each category section, check if it has any visible items
categorySections.forEach((categorySection) => {
const categoryId = categorySection.getAttribute('data-category');
const items = categorySection.querySelectorAll(contentSelector);
// Count visible items in this category
const visibleItems = Array.from(items).filter(item =>
item.style.display !== 'none'
).length;
// If no visible items and we're searching, hide the category
if (query !== '' && visibleItems === 0) {
categorySection.style.display = 'none';
} else {
categorySection.style.display = '';
}
});
},
updateContainerVisibility(query) {
// If there are container elements that should be hidden when empty
const containers = document.querySelectorAll('.content-container');
if (containers.length > 0) {
containers.forEach((container) => {
const hasVisibleItems = Array.from(
container.querySelectorAll(contentSelector)
).some((item) => item.style.display !== 'none');
if (query === '' || hasVisibleItems) {
container.style.display = '';
} else {
container.style.display = 'none';
}
});
}
},
updateResultsStatus(query, anyResults, count) {
// Update results status
this.hasResults = query === '' || anyResults;
this.visibleCount = count;
// Update screen reader status
const statusEl = document.getElementById('search-status');
if (statusEl) {
if (query === '') {
statusEl.textContent = config.allItemsMessage;
this.visibleCount = document.querySelectorAll(contentSelector).length;
} else if (this.hasResults) {
statusEl.textContent = config.resultCountMessage(count);
} else {
statusEl.textContent = config.noResultsMessage;
}
}
}
};
}

View File

@ -0,0 +1,38 @@
/**
* Content search module for articles and projects
* Provides specialized search functionality for content items
*/
import { initializeBaseSearch } from './baseSearch.js';
/**
* Initialize search functionality for articles
* @returns {Object} Alpine.js data object with search functionality
*/
export function initializeArticlesSearch() {
return initializeBaseSearch('.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'
});
}
/**
* Initialize search functionality for projects
* @returns {Object} Alpine.js data object with search functionality
*/
export function initializeProjectsSearch() {
return initializeBaseSearch('.project-item', {
nameAttribute: 'data-title',
tagsAttribute: 'data-tags',
additionalAttributes: ['data-description', 'data-github', 'data-live'],
noResultsMessage: 'No projects found',
allItemsMessage: 'Showing all projects',
resultCountMessage: (count) => `Found ${count} projects`,
itemLabel: 'projects'
});
}

View File

@ -0,0 +1,24 @@
/**
* Main search module that registers all search components with Alpine.js
*/
import { initializeServicesSearch } from './servicesSearch.js';
import { initializeArticlesSearch, initializeProjectsSearch } from './contentSearch.js';
/**
* Register all search components with Alpine.js
* This function is called when Alpine.js is initialized
*/
export function registerSearchComponents() {
// Register services search
window.Alpine.data('searchServices', initializeServicesSearch);
// Register articles search
window.Alpine.data('searchArticles', initializeArticlesSearch);
// Register projects search
window.Alpine.data('searchProjects', initializeProjectsSearch);
}
// Register components when Alpine.js is initialized
document.addEventListener('alpine:init', registerSearchComponents);

View File

@ -0,0 +1,243 @@
/**
* Services search module for homelab services
* Extends the base search functionality with service-specific features
*/
import { initializeBaseSearch } from './baseSearch.js';
/**
* Initialize search functionality for homelab services
* @returns {Object} Alpine.js data object with search functionality
*/
export function initializeServicesSearch() {
// Create base search with service-specific configuration
const baseSearch = initializeBaseSearch('.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'
});
// Extend with service-specific functionality
return {
...baseSearch,
// View mode properties
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() {
// Call the base init method
baseSearch.init.call(this);
// Apply initial icon size, view mode, and display mode
this.applyIconSize();
this.applyViewMode();
this.applyDisplayMode();
// Save preferences to localStorage if available
this.loadPreferences();
// Listen for window resize events to optimize layout
this.setupResizeListener();
},
// Load user preferences from localStorage
loadPreferences() {
if (typeof localStorage !== 'undefined') {
try {
// Load icon size
const savedIconSize = localStorage.getItem('services-icon-size');
if (savedIconSize) {
this.setIconSize(parseFloat(savedIconSize));
}
// Load view mode
const savedViewMode = localStorage.getItem('services-view-mode');
if (savedViewMode) {
this.setViewMode(savedViewMode);
}
// Load display mode
const savedDisplayMode = localStorage.getItem('services-display-mode');
if (savedDisplayMode) {
this.setDisplayMode(savedDisplayMode);
}
} catch (e) {
console.error('Error loading preferences:', e);
}
}
},
// Save user preferences to localStorage
savePreferences() {
if (typeof localStorage !== 'undefined') {
try {
localStorage.setItem('services-icon-size', this.iconSizeValue.toString());
localStorage.setItem('services-view-mode', this.viewMode);
localStorage.setItem('services-display-mode', this.displayMode);
} catch (e) {
console.error('Error saving preferences:', e);
}
}
},
// Setup listener for window resize events
setupResizeListener() {
const handleResize = () => {
// Switch to list view on small screens if not explicitly set by user
const userHasSetViewMode = localStorage.getItem('services-view-mode') !== null;
if (!userHasSetViewMode) {
const smallScreen = window.innerWidth < 640; // sm breakpoint
if (smallScreen && this.viewMode !== 'list') {
this.setViewMode('list', false); // Don't save to preferences
} else if (!smallScreen && this.viewMode !== 'grid') {
this.setViewMode('grid', false); // Don't save to preferences
}
}
};
// Initial check
handleResize();
// Add resize listener with debounce
let resizeTimeout;
window.addEventListener('resize', () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(handleResize, 250);
});
},
// Icon size methods
setIconSize(size, savePreference = true) {
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();
// Save preference if requested
if (savePreference) {
this.savePreferences();
}
},
// 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();
this.savePreferences();
},
setViewMode(mode, savePreference = true) {
this.viewMode = mode;
this.applyViewMode();
// Save preference if requested
if (savePreference) {
this.savePreferences();
}
},
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();
this.savePreferences();
},
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');
}
}
};
}

View File

@ -3,6 +3,10 @@
@variant dark (&:where(.dark, .dark *)); @variant dark (&:where(.dark, .dark *));
/* Define CSS layers for better organization */
@layer base, components, utilities;
@layer base {
/* Prevent theme flash by hiding content until theme is applied */ /* Prevent theme flash by hiding content until theme is applied */
html:not(.theme-loaded) body { html:not(.theme-loaded) body {
display: none; display: none;
@ -25,6 +29,16 @@ html.theme-loaded body {
box-shadow 0.5s cubic-bezier(0.4, 0, 0.2, 1); box-shadow 0.5s cubic-bezier(0.4, 0, 0.2, 1);
} }
/* Disable transitions when user prefers reduced motion */
@media (prefers-reduced-motion: reduce) {
html.theme-loaded body,
.theme-transition-element {
transition: none !important;
}
}
}
@layer components {
/* Font loading states */ /* Font loading states */
html:not(.fonts-loaded) body { html:not(.fonts-loaded) body {
/* Fallback font metrics that match your custom font */ /* Fallback font metrics that match your custom font */
@ -49,6 +63,25 @@ html.fonts-loaded body {
outline-offset: 2px; outline-offset: 2px;
} }
/* Enhanced focus styles using :has() selector */
/* Apply special styling to parent elements that contain focused elements */
.nav-container:has(:focus-visible) {
background-color: var(--color-zag-bg-hover);
border-radius: 0.5rem;
}
/* Style form groups when they contain invalid inputs */
.form-group:has(input:invalid:not(:placeholder-shown)) {
border-color: var(--color-zag-button-red);
}
/* Style form groups when they contain valid inputs */
.form-group:has(input:valid:not(:placeholder-shown)) {
border-color: var(--color-zag-function);
}
}
@layer base {
/* Font declarations with optimized loading strategies */ /* Font declarations with optimized loading strategies */
@font-face { @font-face {
font-family: "Literata Variable"; font-family: "Literata Variable";
@ -75,8 +108,11 @@ html.fonts-loaded body {
U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191,
U+2193, U+2212, U+2215, U+FEFF, U+FFFD; U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
} }
}
@layer base {
@theme { @theme {
/* Font variables */
--font-mono: "IBM Plex Mono", ui-monospace, monospace; --font-mono: "IBM Plex Mono", ui-monospace, monospace;
--font-display: "press-start-2p", ui-monospace, monospace; --font-display: "press-start-2p", ui-monospace, monospace;
--font-serif: "Literata Variable", ui-serif, Georgia, Cambria, "Times New Roman", Times, serif; --font-serif: "Literata Variable", ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
@ -108,6 +144,24 @@ html.fonts-loaded body {
--color-zag-function: #b8bb26; --color-zag-function: #b8bb26;
--color-zag-string: #8ec07c; --color-zag-string: #8ec07c;
--color-zag-special: #83a598; --color-zag-special: #83a598;
/* Spacing and sizing variables */
--space-xs: 0.25rem;
--space-sm: 0.5rem;
--space-md: 1rem;
--space-lg: 1.5rem;
--space-xl: 2rem;
--space-2xl: 3rem;
/* Animation variables */
--transition-fast: 150ms;
--transition-medium: 300ms;
--transition-slow: 500ms;
--ease-default: cubic-bezier(0.4, 0, 0.2, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--ease-in: cubic-bezier(0.4, 0, 1, 1);
--ease-out: cubic-bezier(0, 0, 0.2, 1);
}
} }
@layer base { @layer base {
@ -116,6 +170,12 @@ html.fonts-loaded body {
--zag-offset: 6px; --zag-offset: 6px;
--zag-transition-duration: 0.15s; --zag-transition-duration: 0.15s;
--zag-transition-timing-function: ease-in-out; --zag-transition-timing-function: ease-in-out;
/* Container query breakpoints */
--container-sm: 20rem; /* 320px */
--container-md: 30rem; /* 480px */
--container-lg: 40rem; /* 640px */
--container-xl: 60rem; /* 960px */
} }
.dark { .dark {
@ -124,6 +184,39 @@ html.fonts-loaded body {
--color-zag-accent: rgba(254, 128, 25, 0.5); /* Dark mode accent border */ --color-zag-accent: rgba(254, 128, 25, 0.5); /* Dark mode accent border */
} }
/* Container query context setup */
.container-query {
container-type: inline-size;
container-name: layout;
}
/* Container query responsive classes */
@container layout (min-width: 20rem) {
.cq\:text-sm {
font-size: 0.875rem;
}
}
@container layout (min-width: 30rem) {
.cq\:text-base {
font-size: 1rem;
}
}
@container layout (min-width: 40rem) {
.cq\:text-lg {
font-size: 1.125rem;
}
}
@container layout (min-width: 60rem) {
.cq\:text-xl {
font-size: 1.25rem;
}
}
}
@layer components {
/* Interactive element base transitions */ /* Interactive element base transitions */
.zag-interactive { .zag-interactive {
position: relative; position: relative;
@ -193,6 +286,26 @@ html.fonts-loaded body {
width: 100%; width: 100%;
} }
/* Enhanced parent-child relationships using :has() */
/* Style card containers that have images */
.card-container:has(img) {
padding-top: 0;
}
/* Style form labels when their inputs are focused */
label:has(+ input:focus-visible) {
color: var(--color-zag-accent-dark);
font-weight: 500;
}
/* Style navigation items that have active links */
.nav-item:has(a.active) {
background-color: var(--color-zag-bg-hover);
border-radius: 0.25rem;
}
}
@layer utilities {
.zag-transition { .zag-transition {
@media (prefers-reduced-motion: no-preference) { @media (prefers-reduced-motion: no-preference) {
transition: transition:
@ -205,7 +318,9 @@ html.fonts-loaded body {
box-shadow var(--zag-transition-duration) var(--zag-transition-timing-function); box-shadow var(--zag-transition-duration) var(--zag-transition-timing-function);
} }
} }
}
@layer components {
/* Theme transition animations for specific elements */ /* Theme transition animations for specific elements */
@keyframes theme-fade-in { @keyframes theme-fade-in {
from { opacity: 0; } from { opacity: 0; }
@ -246,6 +361,17 @@ html.fonts-loaded body {
animation: theme-scale-in 0.5s cubic-bezier(0.4, 0, 0.2, 1) forwards; animation: theme-scale-in 0.5s cubic-bezier(0.4, 0, 0.2, 1) forwards;
} }
/* Disable animations when user prefers reduced motion */
@media (prefers-reduced-motion: reduce) {
.theme-animate-fade,
.theme-animate-slide,
.theme-animate-scale {
animation: none !important;
}
}
}
@layer utilities {
/* Base backgrounds and text */ /* Base backgrounds and text */
.zag-bg { .zag-bg {
background-color: var(--color-zag-light); background-color: var(--color-zag-light);