Compare commits

..

5 Commits

Author SHA1 Message Date
c30948bc55 Add Umami Tracking Information
All checks were successful
Build and Deploy / build (push) Successful in 40s
2025-05-03 20:22:04 -07:00
d3a70d149d Update Socials 2025-05-03 20:00:58 -07:00
1d39887433 Make image only selector more clear 2025-05-03 19:08:16 -07:00
f51fa741cc Make size changes a slider 2025-05-03 19:05:21 -07:00
750fe5c629 Update Component structure 2025-05-03 18:22:25 -07:00
24 changed files with 3617 additions and 1120 deletions

View File

@ -3,15 +3,27 @@
<head>
<meta charset="UTF-8">
<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>
<link rel="icon" href="/favicons/favicon.png" type="image/png">
<link rel="apple-touch-icon" href="/favicons/apple-touch-icon.png">
<style>
:root {
--color-bg: #fbf1c7;
--color-text: #3c3836;
--color-accent: #d65d0e;
--color-accent: #fe8019;
--color-accent-secondary: #b8bb26;
--color-muted: #7c6f64;
--font-mono: 'IBM Plex Mono', monospace;
--font-display: 'Press Start 2P', monospace;
--color-card: rgba(235, 219, 178, 0.8);
--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) {
@ -19,10 +31,14 @@
--color-bg: #282828;
--color-text: #ebdbb2;
--color-accent: #fe8019;
--color-accent-secondary: #b8bb26;
--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;
margin: 0;
@ -38,6 +54,7 @@
flex-direction: column;
min-height: 100vh;
padding: 2rem;
transition: background-color 0.3s ease, color 0.3s ease;
}
main {
@ -53,55 +70,109 @@
h1 {
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;
color: var(--color-accent);
}
p {
margin-bottom: 1.5rem;
font-size: 1.1rem;
font-size: clamp(0.9rem, 2vw, 1.1rem);
max-width: 600px;
}
.offline-icon {
font-size: 4rem;
font-size: clamp(3rem, 10vw, 5rem);
margin-bottom: 2rem;
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 {
display: inline-block;
display: inline-flex;
align-items: center;
justify-content: center;
background-color: var(--color-accent);
color: var(--color-bg);
padding: 0.75rem 1.5rem;
border-radius: 0.25rem;
border-radius: var(--radius-md);
text-decoration: none;
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;
margin-top: 1rem;
border: none;
font-family: var(--font-mono);
font-size: 1rem;
min-width: 180px;
}
.button:hover {
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 {
margin-top: 2rem;
text-align: left;
width: 100%;
max-width: 400px;
}
.cached-pages h2 {
font-size: 1.2rem;
margin-bottom: 1rem;
color: var(--color-accent);
}
.cached-pages ul {
list-style: none;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.75rem;
margin-top: 1rem;
}
.cached-pages li {
@ -113,37 +184,126 @@
text-decoration: none;
border-bottom: 1px solid var(--color-accent);
padding-bottom: 2px;
display: inline-block;
transition: color 0.2s, transform 0.2s;
}
.cached-pages a:hover {
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 {
0% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.6;
opacity: 0.7;
transform: scale(0.95);
}
100% {
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>
</head>
<body>
<main>
<div class="offline-icon">📶</div>
<h1>You're Offline</h1>
<p>It looks like you've lost your internet connection. Some pages may still be available if you've visited them before.</p>
<button class="button" id="retry-button">Retry Connection</button>
<div class="status-indicator">
<span class="status-indicator-icon">⚠️</span>
<span>You are currently offline</span>
</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>
<p>Loading cached pages...</p>
<ul id="cached-pages-list"></ul>
</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>
<script>
@ -154,13 +314,35 @@
// Update UI based on connection status
function updateConnectionStatus() {
const statusIndicator = document.querySelector('.status-indicator');
const statusIcon = document.querySelector('.status-indicator-icon');
const statusText = statusIndicator.querySelector('span:last-child');
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();
}, { once: true });
} else {
// Still offline
statusIndicator.classList.remove('online');
statusIcon.textContent = '⚠️';
statusText.textContent = 'You are currently offline';
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('offline', updateConnectionStatus);
// Initial status check
updateConnectionStatus();
// Retry button
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('h1').textContent = "Checking Connection...";
// Try to fetch the homepage
fetch('/')
fetch('/', { cache: 'no-store' })
.then(() => {
// If successful, we're online
window.location.reload();
updateConnectionStatus();
button.disabled = false;
})
.catch(() => {
// Still offline
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
if ('caches' in window && 'serviceWorker' in navigator) {
caches.open('justin-deal-v1')
.then(cache => {
return cache.keys()
.then(requests => {
// Clear cache button
document.getElementById('clear-cache-button').addEventListener('click', () => {
if ('caches' in window) {
caches.keys().then(cacheNames => {
return Promise.all(
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');
if (requests.length === 0) {
document.getElementById('cached-pages').innerHTML = '<h2>No Cached Pages Available</h2><p>Try visiting some pages when you\'re back online.</p>';
// Check if caches API is available
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;
}
// Filter for HTML pages
const htmlRequests = requests.filter(request => {
// Get all cache stores
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);
return url.pathname === '/' ||
const isHtmlPage = url.pathname === '/' ||
url.pathname.endsWith('.html') ||
!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
@ -232,21 +482,21 @@
});
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;
}
cachedPagesList.innerHTML = listItems.join('');
document.querySelector('#cached-pages p').style.display = 'none';
});
})
.catch(error => {
}).catch(error => {
console.error('Error accessing cache:', error);
document.getElementById('cached-pages').innerHTML = '<h2>Could Not Access Cache</h2><p>There was an error accessing the cached pages.</p>';
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>
</body>
</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
* 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
const PRECACHE_ASSETS = [
@ -11,6 +29,7 @@ const PRECACHE_ASSETS = [
'/index.html',
'/favicon.svg',
'/site.webmanifest',
'/offline.html',
'/favicons/favicon.png',
'/favicons/apple-touch-icon.png',
'/favicons/favicon-16x16.png',
@ -36,40 +55,110 @@ const ROUTE_STRATEGIES = [
// HTML pages - network first
{
pattern: /\.html$|\/$/,
strategy: CACHE_STRATEGIES.NETWORK_FIRST
strategy: CACHE_STRATEGIES.NETWORK_FIRST,
cacheName: STATIC_CACHE_NAME
},
// CSS and JS - stale while revalidate
{
pattern: /\.(css|js)$/,
strategy: CACHE_STRATEGIES.STALE_WHILE_REVALIDATE
strategy: CACHE_STRATEGIES.STALE_WHILE_REVALIDATE,
cacheName: STATIC_CACHE_NAME
},
// Images - cache first
{
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
{
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\//,
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) {
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
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
caches.open(STATIC_CACHE_NAME)
.then(cache => cache.addAll(PRECACHE_ASSETS))
.then(() => self.skipWaiting())
);
@ -81,7 +170,11 @@ self.addEventListener('activate', event => {
caches.keys().then(cacheNames => {
return Promise.all(
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))
);
}).then(() => self.clients.claim())
@ -89,15 +182,21 @@ self.addEventListener('activate', event => {
});
// Helper function to handle network-first strategy
async function networkFirstStrategy(request) {
async function networkFirstStrategy(request, cacheName) {
try {
// Try network first
const networkResponse = await fetch(request);
// If successful, clone and cache the response
if (networkResponse.ok) {
const cache = await caches.open(CACHE_NAME);
const cache = await caches.open(cacheName);
cache.put(request, networkResponse.clone());
// Store metadata with timestamp
storeCacheMetadata(cacheName, request.url, {
timestamp: Date.now(),
url: request.url
});
}
return networkResponse;
@ -114,45 +213,104 @@ async function networkFirstStrategy(request) {
}
// Helper function to handle cache-first strategy
async function cacheFirstStrategy(request) {
async function cacheFirstStrategy(request, cacheName) {
// Try cache first
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;
}
// If not in cache, get from network
// If not in cache or expired, get from network
try {
const networkResponse = await fetch(request);
// Cache the response for future
if (networkResponse.ok) {
const cache = await caches.open(CACHE_NAME);
const cache = await caches.open(cacheName);
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;
} 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
async function staleWhileRevalidateStrategy(request) {
// If no cache, throw error
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
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)
const fetchPromise = fetch(request)
.then(networkResponse => {
if (networkResponse.ok) {
const cache = caches.open(CACHE_NAME);
cache.then(cache => cache.put(request, networkResponse.clone()));
// Clone the response before using it
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;
})
.catch(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 cachedResponse || fetchPromise;
// Return cached response immediately if available and not expired
// Otherwise wait for the network response
return cachedResponse && !isExpired ? cachedResponse : fetchPromise;
}
// Fetch event - handle all fetch requests
@ -164,21 +322,21 @@ self.addEventListener('fetch', event => {
return;
}
// Get the appropriate strategy for this URL
const strategy = getStrategyForUrl(event.request.url);
// Get the appropriate strategy and cache name for this URL
const { strategy, cacheName } = getStrategyForUrl(event.request.url);
// Apply the selected strategy
switch (strategy) {
case CACHE_STRATEGIES.NETWORK_FIRST:
event.respondWith(networkFirstStrategy(event.request));
event.respondWith(networkFirstStrategy(event.request, cacheName));
break;
case CACHE_STRATEGIES.CACHE_FIRST:
event.respondWith(cacheFirstStrategy(event.request));
event.respondWith(cacheFirstStrategy(event.request, cacheName));
break;
case CACHE_STRATEGIES.STALE_WHILE_REVALIDATE:
event.respondWith(staleWhileRevalidateStrategy(event.request));
event.respondWith(staleWhileRevalidateStrategy(event.request, cacheName));
break;
case CACHE_STRATEGIES.CACHE_ONLY:
@ -191,7 +349,7 @@ self.addEventListener('fetch', event => {
default:
// 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') {
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,342 @@
---
---
<script is:inline>
/**
* Initialize search functionality for any content type
* @returns {Object} Alpine.js data object with search functionality
* SearchScript component
* 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',
iconSizeValue: 2, // Slider value: 1=small, 2=medium, 3=large
viewMode: 'grid',
displayMode: 'both',
return {
init() {
// Load preferences from localStorage
this.loadPreferences();
// Set initial iconSizeValue based on iconSize
this.updateSliderFromIconSize();
// Listen for events from searchServices component
window.addEventListener('styleControls:updateIconSize', (e) => {
if (e.detail && e.detail.size) {
this.iconSize = e.detail.size;
this.updateSliderFromIconSize();
}
});
window.addEventListener('styleControls:updateViewMode', (e) => {
if (e.detail && e.detail.mode) {
this.viewMode = e.detail.mode;
}
});
window.addEventListener('styleControls:updateDisplayMode', (e) => {
if (e.detail && e.detail.mode) {
this.displayMode = e.detail.mode;
}
});
},
loadPreferences() {
if (typeof localStorage !== 'undefined') {
try {
// Load icon size
const savedIconSize = localStorage.getItem('services-icon-size');
if (savedIconSize) {
this.setIconSize(savedIconSize, false); // Don't update slider yet
}
// 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;
}
// Update slider value based on loaded icon size
this.updateSliderFromIconSize();
} 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);
}
}
},
// Convert between iconSize string and iconSizeValue number
updateSliderFromIconSize() {
if (this.iconSize === 'small') {
this.iconSizeValue = 1;
} else if (this.iconSize === 'medium') {
this.iconSizeValue = 2;
} else if (this.iconSize === 'large') {
this.iconSizeValue = 3;
}
},
// Update iconSize based on slider value
updateIconSizeFromSlider() {
console.log('Slider value changed:', this.iconSizeValue);
const value = parseInt(this.iconSizeValue);
let size;
if (value === 1) {
size = 'small';
} else if (value === 2) {
size = 'medium';
} else if (value === 3) {
size = 'large';
} else {
size = 'medium'; // Default fallback
}
console.log('Setting icon size to:', size);
this.setIconSize(size);
// Apply the size directly to app-list if it exists
this.applyIconSizeDirectly(size);
},
// Apply icon size directly to elements
applyIconSizeDirectly(size) {
// Try to find app-list element
const appList = document.getElementById('app-list');
if (appList) {
console.log('Found app-list, applying size:', size);
// 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-${size}`);
} else {
console.log('app-list element not found');
// Try to apply to all service cards directly
const cards = document.querySelectorAll('.app-card, .service-card');
if (cards.length > 0) {
console.log('Found', cards.length, 'cards, applying size:', size);
cards.forEach(card => {
// Remove existing size classes
card.classList.remove('icon-size-small', 'icon-size-medium', 'icon-size-large');
// Add the new size class
card.classList.add(`icon-size-${size}`);
});
} else {
console.log('No service cards found');
}
}
// Dispatch event to notify searchServices component
window.dispatchEvent(new CustomEvent('searchServices:setIconSize', {
detail: { size }
}));
},
setIconSize(size, updateSlider = true) {
console.log('setIconSize called with:', size);
this.iconSize = size;
// Update slider value if requested
if (updateSlider) {
this.updateSliderFromIconSize();
}
this.savePreferences();
// Apply the size directly
this.applyIconSizeDirectly(size);
},
toggleViewMode() {
this.viewMode = this.viewMode === 'grid' ? 'list' : 'grid';
this.savePreferences();
// Apply view mode directly
this.applyViewModeDirectly(this.viewMode);
// Dispatch event to notify searchServices component
window.dispatchEvent(new CustomEvent('searchServices:setViewMode', {
detail: { mode: this.viewMode }
}));
},
applyViewModeDirectly(mode) {
console.log('Applying view mode directly:', mode);
const appList = document.getElementById('app-list');
if (appList) {
// 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-${mode}`);
// 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 (mode === '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');
}
}
});
}
},
setDisplayMode(mode) {
this.displayMode = mode;
this.savePreferences();
// Apply display mode directly
this.applyDisplayModeDirectly(mode);
// Dispatch event to notify searchServices component
window.dispatchEvent(new CustomEvent('searchServices:setDisplayMode', {
detail: { mode }
}));
},
applyDisplayModeDirectly(mode) {
console.log('Applying display mode directly:', mode);
const appList = document.getElementById('app-list');
if (appList) {
// Remove existing display mode classes
appList.classList.remove('display-both', 'display-image-only', 'display-name-only');
// Add the new display mode class
if (mode === 'image') {
appList.classList.add('display-image-only');
} else if (mode === 'name') {
appList.classList.add('display-name-only');
} else {
appList.classList.add('display-both');
}
}
}
}));
// Register searchServices component
Alpine.data('searchServices', () => ({
searchQuery: '',
hasResults: true,
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() {
// Initialize the visible count
this.visibleCount = document.querySelectorAll(contentSelector).length;
this.visibleCount = document.querySelectorAll('.app-card').length;
this.setupWatchers();
this.setupKeyboardShortcuts();
// Handle theme changes
window.addEventListener('theme-changed', () => {
this.filterContent(this.searchQuery);
// 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();
// Listen for events from styleControls component
this.setupStyleControlsListeners();
// Set loading to false after initialization
setTimeout(() => {
this.loading = false;
}, 300);
},
// Setup listeners for styleControls events
setupStyleControlsListeners() {
// Listen for icon size changes
window.addEventListener('searchServices:setIconSize', (e) => {
if (e.detail && e.detail.size) {
console.log('searchServices received icon size event:', e.detail.size);
this.setIconSize(e.detail.size);
// Notify styleControls component of the change
window.dispatchEvent(new CustomEvent('styleControls:updateIconSize', {
detail: { size: e.detail.size }
}));
}
});
// Listen for view mode changes
window.addEventListener('searchServices:setViewMode', (e) => {
if (e.detail && e.detail.mode) {
console.log('searchServices received view mode event:', e.detail.mode);
this.setViewMode(e.detail.mode);
// Notify styleControls component of the change
window.dispatchEvent(new CustomEvent('styleControls:updateViewMode', {
detail: { mode: e.detail.mode }
}));
}
});
// Listen for display mode changes
window.addEventListener('searchServices:setDisplayMode', (e) => {
if (e.detail && e.detail.mode) {
console.log('searchServices received display mode event:', e.detail.mode);
this.setDisplayMode(e.detail.mode);
// Notify styleControls component of the change
window.dispatchEvent(new CustomEvent('styleControls:updateDisplayMode', {
detail: { mode: e.detail.mode }
}));
}
});
},
setupWatchers() {
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);
}, 150);
});
},
setupKeyboardShortcuts() {
// Track the currently focused item index
this.focusedItemIndex = -1;
document.addEventListener('keydown', (e) => {
// '/' key focuses the search input
if (e.key === '/' && document.activeElement.id !== 'app-search') {
@ -63,239 +351,82 @@ function initializeSearch(contentSelector = '.searchable-item', options = {}) {
this.focusedItemIndex = -1;
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() {
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');
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';
// 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(savedIconSize);
}
});
// Update category visibility for homelab page
this.updateCategoryVisibility(query);
// Update parent containers if needed
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 = '';
// Load view mode
const savedViewMode = localStorage.getItem('services-view-mode');
if (savedViewMode) {
this.setViewMode(savedViewMode);
}
});
},
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';
// Load display mode
const savedDisplayMode = localStorage.getItem('services-display-mode');
if (savedDisplayMode) {
this.setDisplayMode(savedDisplayMode);
}
} catch (e) {
console.error('Error loading preferences:', e);
}
});
}
},
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;
// Save user preferences to localStorage
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);
}
}
},
// 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
document.addEventListener('alpine:init', () => {
// Homelab search
window.Alpine.data('searchServices', () => {
const baseSearch = initializeSearch('.app-card', {
nameAttribute: 'data-app-name',
tagsAttribute: 'data-app-tags',
categoryAttribute: 'data-app-category',
noResultsMessage: 'No services found',
allItemsMessage: 'Showing all services',
resultCountMessage: (count) => `Found ${count} services`,
itemLabel: 'services'
// Initial check
handleResize();
// Add resize listener with debounce
let resizeTimeout;
window.addEventListener('resize', () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(handleResize, 250);
});
// Add icon size slider functionality
return {
...baseSearch,
iconSizeValue: 2, // Slider value: 1=small, 2=medium, 3=large
iconSize: 'medium', // small, medium, large
viewMode: 'grid', // grid or list
displayMode: 'both', // both, image, or name
debounceTimeout: null, // For debouncing slider changes
init() {
baseSearch.init.call(this);
// Apply initial icon size, view mode, and display mode
this.applyIconSize();
this.applyViewMode();
this.applyDisplayMode();
},
// Icon size methods
setIconSize(size) {
if (typeof size === 'string') {
// Handle legacy string values (small, medium, large)
this.iconSize = size;
this.iconSizeValue = size === 'small' ? 1 : size === 'medium' ? 2 : 3;
} else {
// Handle slider numeric values
this.iconSizeValue = parseFloat(size);
// Map slider value to size name
if (this.iconSizeValue <= 1.33) {
this.iconSize = 'small';
} else if (this.iconSizeValue <= 2.33) {
this.iconSize = 'medium';
} else {
this.iconSize = 'large';
}
}
this.applyIconSize();
},
// Handle slider input with debounce
handleSliderChange(event) {
const value = event.target.value;
// Clear any existing timeout
if (this.debounceTimeout) {
clearTimeout(this.debounceTimeout);
}
// Set a new timeout
this.debounceTimeout = setTimeout(() => {
this.setIconSize(value);
}, 50); // 50ms debounce
this.savePreferences();
},
applyIconSize() {
@ -307,20 +438,23 @@ document.addEventListener('alpine:init', () => {
// 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) {
setViewMode(mode, savePreference = true) {
this.viewMode = mode;
this.applyViewMode();
// Save preference if requested
if (savePreference) {
this.savePreferences();
}
},
applyViewMode() {
@ -353,6 +487,7 @@ document.addEventListener('alpine:init', () => {
setDisplayMode(mode) {
this.displayMode = mode;
this.applyDisplayMode();
this.savePreferences();
},
applyDisplayMode() {
@ -370,49 +505,225 @@ document.addEventListener('alpine:init', () => {
} else {
appList.classList.add('display-both');
}
},
// Update all category sections
document.querySelectorAll('.category-section').forEach(section => {
const gridContainer = section.querySelector('.grid');
if (gridContainer) {
// Update grid classes based on view mode
if (this.viewMode === 'grid') {
gridContainer.classList.remove('grid-cols-1');
gridContainer.classList.add('grid-cols-2', 'sm:grid-cols-3', 'lg:grid-cols-4');
filterContent(query) {
query = query.toLowerCase().trim();
let anyResults = false;
let visibleCount = 0;
// Process all content items
document.querySelectorAll('.app-card').forEach((item) => {
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 {
gridContainer.classList.remove('grid-cols-2', 'sm:grid-cols-3', 'lg:grid-cols-4');
gridContainer.classList.add('grid-cols-1');
item.style.display = 'none';
}
}
});
}
};
});
// Blog search
window.Alpine.data('searchArticles', () => {
return initializeSearch('.article-item', {
nameAttribute: 'data-title',
tagsAttribute: 'data-tags',
additionalAttributes: ['data-description'],
noResultsMessage: 'No articles found',
allItemsMessage: 'Showing all articles',
resultCountMessage: (count) => `Found ${count} articles`,
itemLabel: 'articles'
});
// Update category visibility
document.querySelectorAll('.category-section').forEach((categorySection) => {
const items = categorySection.querySelectorAll('.app-card');
// 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 = '';
}
});
// Projects search
window.Alpine.data('searchProjects', () => {
return initializeSearch('.project-item', {
nameAttribute: 'data-title',
tagsAttribute: 'data-tags',
additionalAttributes: ['data-description', 'data-github', 'data-live'],
noResultsMessage: 'No projects found',
allItemsMessage: 'Showing all projects',
resultCountMessage: (count) => `Found ${count} projects`,
itemLabel: 'projects'
// Update results status
this.hasResults = query === '' || anyResults;
this.visibleCount = visibleCount;
}
}));
// Register searchArticles component
Alpine.data('searchArticles', () => ({
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('.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>

View File

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

View File

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

View File

@ -13,41 +13,71 @@ const {
class: className = '',
} = Astro.props;
const sizeMap = {
small: 'w-4 h-4',
medium: 'w-8 h-8',
large: 'w-12 h-12',
// Map sizes to CSS classes
const sizeClasses = {
small: 'size-sm',
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' && (
<div class:list={['loading-spinner', sizeClass, className]} style={{ '--spinner-color': color }}>
<>
<div class="spinner-ring"></div>
<div class="spinner-ring"></div>
<div class="spinner-ring"></div>
</div>
</>
)}
{type === 'dots' && (
<div class:list={['loading-dots', sizeClass, className]} style={{ '--dots-color': color }}>
<>
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
</div>
</>
)}
{type === 'pulse' && (
<div class:list={['loading-pulse', sizeClass, className]} style={{ '--pulse-color': color }}>
</div>
<div class="pulse-element"></div>
)}
</div>
<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 {
position: relative;
display: inline-block;
}
.spinner-ring {
@ -57,7 +87,7 @@ const sizeClass = sizeMap[size] || sizeMap.medium;
width: 100%;
height: 100%;
border: 2px solid transparent;
border-top-color: var(--spinner-color, currentColor);
border-top-color: var(--indicator-color, currentColor);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@ -75,18 +105,15 @@ const sizeClass = sizeMap[size] || sizeMap.medium;
100% { transform: rotate(360deg); }
}
/* Dots animation */
/* Dots type */
.loading-dots {
display: flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
}
.loading-dots .dot {
width: 25%;
height: 25%;
background-color: var(--dots-color, currentColor);
background-color: var(--indicator-color, currentColor);
border-radius: 50%;
animation: dotBounce 1.4s infinite ease-in-out both;
}
@ -104,9 +131,15 @@ const sizeClass = sizeMap[size] || sizeMap.medium;
40% { transform: scale(1); }
}
/* Pulse animation */
/* Pulse type */
.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%;
animation: pulse 1.5s ease-in-out infinite;
}
@ -119,8 +152,12 @@ const sizeClass = sizeMap[size] || sizeMap.medium;
/* Respect reduced motion preferences */
@media (prefers-reduced-motion: reduce) {
.spinner-ring, .loading-dots .dot, .loading-pulse {
animation: none;
.spinner-ring, .dot, .pulse-element {
animation: none !important;
}
.pulse-element {
opacity: 0.7;
}
}
</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}`}
role="alert"
aria-live="polite"
aria-atomic="true"
data-loading-overlay
>
<div class="loading-content text-center p-8 rounded-lg">
<!-- Enhanced loading indicator -->
@ -73,15 +75,87 @@ const {
</style>
<script>
// This script will be executed client-side
// Enhanced loading manager with Intersection Observer and performance optimizations
class LoadingManager {
overlay: HTMLElement | null = null;
timeoutId: ReturnType<typeof setTimeout> | null = null;
longLoadingTimeoutId: ReturnType<typeof setTimeout> | null = null;
loadingElements: Set<Element> = new Set();
observer: IntersectionObserver | null = null;
constructor() {
this.overlay = document.querySelector('.loading-overlay');
this.overlay = document.querySelector('[data-loading-overlay]');
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() {
@ -113,6 +187,8 @@ const {
// For SPA navigation (if using client-side routing)
document.addEventListener('astro:page-load', () => {
this.hideLoading();
// Re-observe loading elements after page load
this.observeLoadingElements();
});
}
@ -123,10 +199,15 @@ const {
document.addEventListener('astro:after-swap', () => {
this.hideLoading();
// Re-observe loading elements after view transition
this.observeLoadingElements();
});
}
showLoading() {
// Use requestIdleCallback for non-critical UI updates if available
const scheduleUpdate = window.requestIdleCallback || window.requestAnimationFrame || setTimeout;
// Clear any existing timeouts
if (this.timeoutId) {
clearTimeout(this.timeoutId);
@ -143,6 +224,7 @@ const {
// Only show loading UI after a short delay to avoid flashing on fast loads
this.timeoutId = setTimeout(() => {
scheduleUpdate(() => {
// Set loading state in Alpine.js components if they exist
document.querySelectorAll('[x-data]').forEach(el => {
// @ts-ignore
@ -151,6 +233,7 @@ const {
el.__x.$data.loading = true;
}
});
});
}, showDelay);
// Show the overlay for long loading times (5+ seconds)
@ -216,10 +299,35 @@ const {
// Default delay if Network Information API is not available
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
let loadingManager: LoadingManager | null = null;
document.addEventListener('DOMContentLoaded', () => {
new LoadingManager();
loadingManager = new LoadingManager();
});
// Clean up on page unload
window.addEventListener('beforeunload', () => {
if (loadingManager) {
loadingManager.destroy();
loadingManager = null;
}
});
</script>

View File

@ -3,103 +3,92 @@ import { Image } from 'astro:assets';
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
* @example
* ```astro
* <ResponsiveImage
* src={import('../assets/hero.jpg')}
* alt="Hero image"
* width={1200}
* height={600}
* priority={true}
* />
* ```
*/
interface Props {
/**
* The image source (either an imported image or a URL)
*/
/** The image source (either imported via astro:assets or a URL string) */
src: ImageMetadata | string;
/**
* Alternative text for the image
*/
/** Alternative text for the image (required for accessibility) */
alt: string;
/**
* Base width of the image
*/
/** Width of the image in pixels */
width?: number;
/**
* Base height of the image
*/
/** Height of the image in pixels */
height?: number;
/**
* CSS class to apply to the image
*/
/** Additional CSS classes to apply to the container */
class?: string;
/**
* Sizes attribute for responsive images
* @default "(min-width: 1280px) 1280px, (min-width: 1024px) 1024px, (min-width: 768px) 768px, 100vw"
*/
/** Responsive sizes attribute (e.g. "(max-width: 768px) 100vw, 768px") */
sizes?: string;
/**
* Loading strategy
* @default "lazy"
*/
/** Loading strategy ('lazy' or 'eager') */
loading?: 'eager' | 'lazy';
/**
* Decoding strategy
* @default "async"
*/
/** Decoding strategy */
decoding?: 'sync' | 'async' | 'auto';
/**
* Image format
* @default "auto"
*/
/** Image format to convert to */
format?: 'webp' | 'avif' | 'png' | 'jpeg' | 'jpg' | 'auto';
/**
* Image quality (1-100)
* @default 80
*/
/** Image quality (1-100) */
quality?: number;
/**
* Whether to add a blur-up effect
* @default false
*/
/** Whether to apply a blur-up loading effect */
blurUp?: boolean;
/**
* Whether this is a priority image (above the fold)
* @default false
*/
/** Whether this is a high-priority image (sets fetchpriority="high" and loading="eager") */
priority?: boolean;
/**
* Breakpoints for responsive images
* @default [640, 768, 1024, 1280]
*/
/** Breakpoints for responsive images */
breakpoints?: number[];
/**
* Whether to use art direction (different images for different breakpoints)
* @default false
*/
/** Whether to use art direction with different image sources for different screen sizes */
artDirected?: boolean;
/**
* Mobile image source (for art direction)
*/
/** Source for mobile screens (max-width: 640px) */
mobileSrc?: ImageMetadata | string;
/**
* Tablet image source (for art direction)
*/
/** Source for tablet screens (641px to 1023px) */
tabletSrc?: ImageMetadata | string;
/**
* Desktop image source (for art direction)
*/
/** Source for desktop screens (min-width: 1024px) */
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 {
@ -119,41 +108,141 @@ const {
artDirected = false,
mobileSrc,
tabletSrc,
desktopSrc
desktopSrc,
additionalSources = [],
container = true,
aspectRatio,
nativeLazy = true
} = Astro.props;
// Determine loading strategy based on priority
const loading = priority ? 'eager' : propLoading || 'lazy';
// Generate a unique ID for this image instance
const imageId = `img-${Math.random().toString(36).substring(2, 11)}`;
// Set fetchpriority based on priority
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
const isStringSource = typeof src === 'string';
// Placeholder for blur-up effect (simplified version)
const placeholderStyle = blurUp ? 'filter: blur(20px); transition: filter 0.5s ease-out;' : '';
// Simplified placeholder style
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 ? (
<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 */}
{mobileSrc && (
<source
media="(max-width: 640px)"
srcset={typeof mobileSrc === 'string' ? mobileSrc : typeof src === 'string' ? src : ''}
srcset={typeof mobileSrc === 'string' ? mobileSrc : ''}
/>
)}
{/* Tablet image */}
{tabletSrc && (
<source
media="(min-width: 641px) and (max-width: 1023px)"
srcset={typeof tabletSrc === 'string' ? tabletSrc : typeof src === 'string' ? src : ''}
srcset={typeof tabletSrc === 'string' ? tabletSrc : ''}
/>
)}
{/* Desktop image */}
{desktopSrc && (
<source
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 */}
{isStringSource ? (
@ -162,11 +251,12 @@ const placeholderStyle = blurUp ? 'filter: blur(20px); transition: filter 0.5s e
alt={alt}
width={width}
height={height}
class={className}
loading={loading}
class={`responsive-img ${placeholderClass}`}
loading={nativeLazy ? loading : undefined}
decoding={decoding}
fetchpriority={fetchPriority}
id={imageId}
style={placeholderStyle}
data-loading={!nativeLazy && loading === 'lazy' ? 'true' : undefined}
/>
) : (
<Image
@ -174,28 +264,66 @@ const placeholderStyle = blurUp ? 'filter: blur(20px); transition: filter 0.5s e
alt={alt}
width={width}
height={height}
class={className}
class={`responsive-img ${placeholderClass}`}
sizes={sizes}
loading={loading}
loading={nativeLazy ? loading : undefined}
decoding={decoding}
quality={quality}
fetchpriority={fetchPriority}
id={imageId}
data-loading={!nativeLazy && loading === 'lazy' ? 'true' : undefined}
/>
)}
</picture>
) : (
/* 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 ? (
<img
src={src}
alt={alt}
width={width}
height={height}
class={className}
loading={loading}
class={`responsive-img ${placeholderClass} ${className}`}
loading={nativeLazy ? loading : undefined}
decoding={decoding}
fetchpriority={fetchPriority}
id={imageId}
style={placeholderStyle}
data-loading={!nativeLazy && loading === 'lazy' ? 'true' : undefined}
/>
) : (
<Image
@ -203,41 +331,132 @@ const placeholderStyle = blurUp ? 'filter: blur(20px); transition: filter 0.5s e
alt={alt}
width={width}
height={height}
class={className}
class={`responsive-img ${placeholderClass} ${className}`}
sizes={sizes}
loading={loading}
loading={nativeLazy ? loading : undefined}
decoding={decoding}
format={format === 'auto' ? undefined : format}
quality={quality}
fetchpriority={fetchPriority}
id={imageId}
data-loading={!nativeLazy && loading === 'lazy' ? 'true' : undefined}
/>
)
)
)}
{blurUp && (
<script define:vars={{ imageId }}>
// Simple blur-up effect
{(blurUp || !nativeLazy) && (
<script define:vars={{ imageId, blurUp, nativeLazy, loading }}>
// Enhanced image loading with IntersectionObserver
document.addEventListener('DOMContentLoaded', () => {
const img = document.getElementById(imageId);
if (img) {
img.onload = () => {
img.style.filter = 'blur(0)';
};
if (!img) return;
// 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>
)}
<style>
/* Prevent layout shifts by maintaining aspect ratio */
img {
<style define:vars={{ aspectRatioStyle }}>
/* Base container styles */
.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;
max-width: 100%;
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 */
img:not([loading="eager"]) {
img:not([loading="eager"]):not(.blur-up) {
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; }
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>

View File

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

View File

@ -35,26 +35,33 @@ interface 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
id={cardId}
href={href}
class="service-card zag-interactive flex items-center transition-all duration-300"
class="service-card zag-interactive flex items-center"
target="_blank"
rel="noopener noreferrer"
aria-label={`Open ${name} in a new tab`}
>
<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
src={img}
alt={alt}
class="service-icon w-16 h-16 transition-all duration-300 relative z-10"
class="service-icon"
loading="lazy"
decoding="async"
width="64"
height="64"
fetchpriority="low"
/>
</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 -->
<div class="print-qr-code">
@ -64,18 +71,138 @@ const { name, href, img, alt } = Astro.props;
</a>
<style>
/* Default (grid) view */
/* Base card styles with CSS custom properties */
.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;
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 {
flex-direction: row;
justify-content: flex-start;
padding: 0.5rem;
border-radius: 0.375rem;
padding: var(--card-padding);
}
:global(.view-mode-list) .service-name {
@ -84,12 +211,15 @@ const { name, href, img, alt } = Astro.props;
text-align: left;
}
/* Display mode styles */
/* Default display mode (both) */
.service-icon-container, .service-name {
display: block;
: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);
}
/* Display mode styles */
/* Image only mode */
:global(.display-image-only) .service-name {
display: none;
@ -119,125 +249,31 @@ const { name, href, img, alt } = Astro.props;
:global(.view-mode-list.display-image-only) .service-card {
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) {
--icon-scale: 2; /* Default medium size */
--icon-base-size: 1rem;
}
.service-icon {
width: calc(var(--icon-base-size) * var(--icon-scale) * 2);
height: calc(var(--icon-base-size) * var(--icon-scale) * 2);
}
/* Fallback discrete sizes for browsers that don't support calc */
:global(.icon-size-small) .service-icon {
width: 2rem;
height: 2rem;
--icon-size: 2rem;
}
:global(.icon-size-medium) .service-icon {
width: 4rem;
height: 4rem;
--icon-size: 4rem;
}
:global(.icon-size-large) .service-icon {
width: 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);
--icon-size: 6rem;
}
/* Dark mode adjustments */
:global(.dark) .service-card {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
:global(.dark) .service-card:hover {
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.4);
--card-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
--card-hover-shadow: 0 10px 20px rgba(0, 0, 0, 0.4);
}
/* Print-specific styles */
@ -304,4 +340,27 @@ const { name, href, img, alt } = Astro.props;
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>

View File

@ -16,40 +16,18 @@ const {
{showSizeSelector && (
<div class="flex items-center gap-2">
<span class="text-sm zag-text-muted hidden sm:inline">Size:</span>
<div class="size-selector flex items-center gap-1 border-2 border-solid zag-border-b rounded-lg p-1 zag-bg zag-transition">
<button
@click="setIconSize('small')"
:class="iconSize === 'small' ? 'active-size' : 'inactive-size'"
class="p-1.5 transition-all rounded-md focus:outline-none focus:ring-2 focus:ring-current"
aria-label="Small icons"
title="Small icons (Alt+1)"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
</svg>
</button>
<button
@click="setIconSize('medium')"
:class="iconSize === 'medium' ? 'active-size' : 'inactive-size'"
class="p-1.5 transition-all rounded-md focus:outline-none focus:ring-2 focus:ring-current"
aria-label="Medium icons"
title="Medium icons (Alt+2)"
>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
</svg>
</button>
<button
@click="setIconSize('large')"
:class="iconSize === 'large' ? 'active-size' : 'inactive-size'"
class="p-1.5 transition-all rounded-md focus:outline-none focus:ring-2 focus:ring-current"
aria-label="Large icons"
title="Large icons (Alt+3)"
>
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
</svg>
</button>
<div class="size-selector flex flex-col items-center border-2 border-solid zag-border-b rounded-lg p-2 zag-bg zag-transition">
<input
type="range"
min="1"
max="3"
step="1"
x-model="iconSizeValue"
@input="updateIconSizeFromSlider()"
class="size-slider w-full"
aria-label="Icon size slider"
title="Adjust icon size (Alt+1: Small, Alt+2: Medium, Alt+3: Large)"
/>
</div>
</div>
)}
@ -80,7 +58,8 @@ const {
>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="12" cy="12" r="3"></circle>
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline>
</svg>
</button>
<button
@ -163,4 +142,85 @@ const {
color: var(--color-zag-light);
}
}
/* Slider styles */
.size-slider {
-webkit-appearance: none;
appearance: none;
height: 6px;
border-radius: 3px;
background: var(--color-zag-light-muted);
outline: none;
cursor: pointer;
transition: all 0.2s;
:where(.dark, .dark *) & {
background: var(--color-zag-dark-muted);
}
}
/* Slider thumb */
.size-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--color-zag-dark);
cursor: pointer;
border: 2px solid var(--color-zag-light);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
transition: all 0.2s;
:where(.dark, .dark *) & {
background: var(--color-zag-light);
border: 2px solid var(--color-zag-dark);
}
}
.size-slider::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--color-zag-dark);
cursor: pointer;
border: 2px solid var(--color-zag-light);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
transition: all 0.2s;
:where(.dark, .dark *) & {
background: var(--color-zag-light);
border: 2px solid var(--color-zag-dark);
}
}
/* Hover state */
.size-slider:hover::-webkit-slider-thumb {
transform: scale(1.1);
}
.size-slider:hover::-moz-range-thumb {
transform: scale(1.1);
}
/* Focus state */
.size-slider:focus {
outline: none;
}
.size-slider:focus::-webkit-slider-thumb {
box-shadow: 0 0 0 3px var(--color-zag-light), 0 0 0 5px var(--color-zag-dark-muted);
:where(.dark, .dark *) & {
box-shadow: 0 0 0 3px var(--color-zag-dark), 0 0 0 5px var(--color-zag-light-muted);
}
}
.size-slider:focus::-moz-range-thumb {
box-shadow: 0 0 0 3px var(--color-zag-light), 0 0 0 5px var(--color-zag-dark-muted);
:where(.dark, .dark *) & {
box-shadow: 0 0 0 3px var(--color-zag-dark), 0 0 0 5px var(--color-zag-light-muted);
}
}
</style>

View File

@ -125,6 +125,9 @@ import "../styles/global.css";
<!-- Theme transition script -->
<script src="/src/scripts/ThemeTransition.js"></script>
<!-- Analytics -->
<script defer src="https://analytics.justin.deal/script.js" data-website-id="0b84ab9f-9fb6-4b19-b5a8-5db8fb66e45b" crossorigin="anonymous"></script>
<SearchScript />
<slot name="head" />
</head>

View File

@ -4,14 +4,6 @@ import { type SocialMedia } from "./types";
* Social media profiles
*/
export const socials: SocialMedia[] = [
{
name: "GitHub",
url: "https://github.com/justindeal",
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/github-dark.svg",
alt: "GitHub Profile",
showInFooter: true,
showInAbout: true
},
{
name: "Gitea",
url: "https://code.justin.deal/dealjus",
@ -27,5 +19,13 @@ export const socials: SocialMedia[] = [
alt: "LinkedIn Profile",
showInFooter: true,
showInAbout: true
},
{
name: "GitHub",
url: "https://github.com/justintdeal",
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/github-dark.svg",
alt: "GitHub Profile",
showInFooter: true,
showInAbout: true
}
];

View File

@ -18,6 +18,9 @@
* @property {string} projectShortDescription - A short description of the projects
* @property {string} projectLongDescription - A longer description of the projects
* @property {string} profileImage - The profile image filename
* @property {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
*/
export const GLOBAL = {
@ -49,6 +52,11 @@ export const GLOBAL = {
// Profile image
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: {
home: "/",

View File

@ -146,7 +146,7 @@ const webpageData = {
<!-- Search and controls container -->
<div class="mb-4 pt-0">
<!-- Style controls in a centered row above search -->
<div class="w-full flex justify-center mb-4">
<div class="w-full flex justify-center mb-4" x-data="styleControls">
<StyleControls />
</div>

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 *));
/* Define CSS layers for better organization */
@layer base, components, utilities;
@layer base {
/* Prevent theme flash by hiding content until theme is applied */
html:not(.theme-loaded) body {
display: none;
@ -25,6 +29,16 @@ html.theme-loaded body {
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 */
html:not(.fonts-loaded) body {
/* Fallback font metrics that match your custom font */
@ -49,6 +63,25 @@ html.fonts-loaded body {
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-face {
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+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
}
@layer base {
@theme {
/* Font variables */
--font-mono: "IBM Plex Mono", ui-monospace, monospace;
--font-display: "press-start-2p", ui-monospace, monospace;
--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-string: #8ec07c;
--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 {
@ -116,6 +170,12 @@ html.fonts-loaded body {
--zag-offset: 6px;
--zag-transition-duration: 0.15s;
--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 {
@ -124,6 +184,61 @@ html.fonts-loaded body {
--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 {
/* Icon size classes for service cards */
.icon-size-small .app-card .app-icon,
.icon-size-small .service-card .service-icon {
width: 32px;
height: 32px;
transition: width 0.3s ease, height 0.3s ease;
}
.icon-size-medium .app-card .app-icon,
.icon-size-medium .service-card .service-icon {
width: 48px;
height: 48px;
transition: width 0.3s ease, height 0.3s ease;
}
.icon-size-large .app-card .app-icon,
.icon-size-large .service-card .service-icon {
width: 64px;
height: 64px;
transition: width 0.3s ease, height 0.3s ease;
}
/* Interactive element base transitions */
.zag-interactive {
position: relative;
@ -193,6 +308,26 @@ html.fonts-loaded body {
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 {
@media (prefers-reduced-motion: no-preference) {
transition:
@ -205,7 +340,9 @@ html.fonts-loaded body {
box-shadow var(--zag-transition-duration) var(--zag-transition-timing-function);
}
}
}
@layer components {
/* Theme transition animations for specific elements */
@keyframes theme-fade-in {
from { opacity: 0; }
@ -246,6 +383,17 @@ html.fonts-loaded body {
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 */
.zag-bg {
background-color: var(--color-zag-light);