Update Component structure
This commit is contained in:
parent
647d98e6b6
commit
750fe5c629
@ -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>© 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>
|
||||
|
257
public/scripts/search/baseSearch.js
Normal file
257
public/scripts/search/baseSearch.js
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
38
public/scripts/search/contentSearch.js
Normal file
38
public/scripts/search/contentSearch.js
Normal 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'
|
||||
});
|
||||
}
|
24
public/scripts/search/index.js
Normal file
24
public/scripts/search/index.js
Normal 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);
|
243
public/scripts/search/servicesSearch.js
Normal file
243
public/scripts/search/servicesSearch.js
Normal 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');
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
@ -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'
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
@ -1,54 +1,131 @@
|
||||
---
|
||||
---
|
||||
|
||||
<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',
|
||||
viewMode: 'grid',
|
||||
displayMode: 'both',
|
||||
|
||||
return {
|
||||
init() {
|
||||
// Load preferences from localStorage
|
||||
this.loadPreferences();
|
||||
},
|
||||
|
||||
loadPreferences() {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
try {
|
||||
// Load icon size
|
||||
const savedIconSize = localStorage.getItem('services-icon-size');
|
||||
if (savedIconSize) {
|
||||
this.setIconSize(savedIconSize);
|
||||
}
|
||||
|
||||
// Load view mode
|
||||
const savedViewMode = localStorage.getItem('services-view-mode');
|
||||
if (savedViewMode) {
|
||||
this.viewMode = savedViewMode;
|
||||
}
|
||||
|
||||
// Load display mode
|
||||
const savedDisplayMode = localStorage.getItem('services-display-mode');
|
||||
if (savedDisplayMode) {
|
||||
this.displayMode = savedDisplayMode;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error loading preferences:', e);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
savePreferences() {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
try {
|
||||
localStorage.setItem('services-icon-size', this.iconSize);
|
||||
localStorage.setItem('services-view-mode', this.viewMode);
|
||||
localStorage.setItem('services-display-mode', this.displayMode);
|
||||
} catch (e) {
|
||||
console.error('Error saving preferences:', e);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
setIconSize(size) {
|
||||
this.iconSize = size;
|
||||
this.savePreferences();
|
||||
},
|
||||
|
||||
toggleViewMode() {
|
||||
this.viewMode = this.viewMode === 'grid' ? 'list' : 'grid';
|
||||
this.savePreferences();
|
||||
},
|
||||
|
||||
setDisplayMode(mode) {
|
||||
this.displayMode = mode;
|
||||
this.savePreferences();
|
||||
}
|
||||
}));
|
||||
|
||||
// Register searchServices component
|
||||
Alpine.data('searchServices', () => ({
|
||||
searchQuery: '',
|
||||
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();
|
||||
|
||||
// Set loading to false after initialization
|
||||
setTimeout(() => {
|
||||
this.loading = false;
|
||||
}, 300);
|
||||
},
|
||||
|
||||
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 +140,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 +227,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 +276,7 @@ document.addEventListener('alpine:init', () => {
|
||||
setDisplayMode(mode) {
|
||||
this.displayMode = mode;
|
||||
this.applyDisplayMode();
|
||||
this.savePreferences();
|
||||
},
|
||||
|
||||
applyDisplayMode() {
|
||||
@ -370,49 +294,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>
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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);
|
||||
|
@ -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>
|
||||
|
@ -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: "/",
|
||||
|
257
src/scripts/search/baseSearch.js
Normal file
257
src/scripts/search/baseSearch.js
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
38
src/scripts/search/contentSearch.js
Normal file
38
src/scripts/search/contentSearch.js
Normal 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'
|
||||
});
|
||||
}
|
24
src/scripts/search/index.js
Normal file
24
src/scripts/search/index.js
Normal 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);
|
243
src/scripts/search/servicesSearch.js
Normal file
243
src/scripts/search/servicesSearch.js
Normal 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');
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
@ -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,39 @@ 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 {
|
||||
/* Interactive element base transitions */
|
||||
.zag-interactive {
|
||||
position: relative;
|
||||
@ -193,6 +286,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 +318,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 +361,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);
|
||||
|
Loading…
x
Reference in New Issue
Block a user