Compare commits
5 Commits
647d98e6b6
...
c30948bc55
Author | SHA1 | Date | |
---|---|---|---|
c30948bc55 | |||
d3a70d149d | |||
1d39887433 | |||
f51fa741cc | |||
750fe5c629 |
@ -3,15 +3,27 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="description" content="You are currently offline. Some pages may still be available if you've visited them before.">
|
||||||
<title>Offline | Justin Deal</title>
|
<title>Offline | Justin Deal</title>
|
||||||
|
<link rel="icon" href="/favicons/favicon.png" type="image/png">
|
||||||
|
<link rel="apple-touch-icon" href="/favicons/apple-touch-icon.png">
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--color-bg: #fbf1c7;
|
--color-bg: #fbf1c7;
|
||||||
--color-text: #3c3836;
|
--color-text: #3c3836;
|
||||||
--color-accent: #d65d0e;
|
--color-accent: #fe8019;
|
||||||
|
--color-accent-secondary: #b8bb26;
|
||||||
--color-muted: #7c6f64;
|
--color-muted: #7c6f64;
|
||||||
--font-mono: 'IBM Plex Mono', monospace;
|
--color-card: rgba(235, 219, 178, 0.8);
|
||||||
--font-display: 'Press Start 2P', monospace;
|
--color-card-hover: rgba(235, 219, 178, 1);
|
||||||
|
--font-mono: 'IBM Plex Mono', monospace, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||||
|
--font-display: 'Press Start 2P', monospace, system-ui;
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
|
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
|
||||||
|
--radius-sm: 0.25rem;
|
||||||
|
--radius-md: 0.5rem;
|
||||||
|
--radius-lg: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
@ -19,10 +31,14 @@
|
|||||||
--color-bg: #282828;
|
--color-bg: #282828;
|
||||||
--color-text: #ebdbb2;
|
--color-text: #ebdbb2;
|
||||||
--color-accent: #fe8019;
|
--color-accent: #fe8019;
|
||||||
|
--color-accent-secondary: #b8bb26;
|
||||||
--color-muted: #a89984;
|
--color-muted: #a89984;
|
||||||
|
--color-card: rgba(40, 40, 40, 0.8);
|
||||||
|
--color-card-hover: rgba(40, 40, 40, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Base styles */
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@ -38,6 +54,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
|
transition: background-color 0.3s ease, color 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
@ -53,55 +70,109 @@
|
|||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
font-size: 2rem;
|
font-size: clamp(1.5rem, 5vw, 2.5rem);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--color-accent);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: clamp(1.1rem, 3vw, 1.5rem);
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
font-size: 1.1rem;
|
font-size: clamp(0.9rem, 2vw, 1.1rem);
|
||||||
|
max-width: 600px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.offline-icon {
|
.offline-icon {
|
||||||
font-size: 4rem;
|
font-size: clamp(3rem, 10vw, 5rem);
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
animation: pulse 2s infinite;
|
animation: pulse 2s infinite;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-icon::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
background: radial-gradient(circle, rgba(254, 128, 25, 0.2) 0%, rgba(254, 128, 25, 0) 70%);
|
||||||
|
border-radius: 50%;
|
||||||
|
z-index: -1;
|
||||||
|
animation: pulse-shadow 2s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button {
|
.button {
|
||||||
display: inline-block;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
background-color: var(--color-accent);
|
background-color: var(--color-accent);
|
||||||
color: var(--color-bg);
|
color: var(--color-bg);
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.75rem 1.5rem;
|
||||||
border-radius: 0.25rem;
|
border-radius: var(--radius-md);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
transition: transform 0.2s, box-shadow 0.2s;
|
transition: transform 0.2s, box-shadow 0.2s, background-color 0.2s;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
|
border: none;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 1rem;
|
||||||
|
min-width: 180px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button:hover {
|
.button:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
box-shadow: var(--shadow-md);
|
||||||
|
background-color: var(--color-accent-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:focus {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-icon {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background-color: var(--color-card);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s, background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
background-color: var(--color-card-hover);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cached-pages {
|
.cached-pages {
|
||||||
margin-top: 2rem;
|
|
||||||
text-align: left;
|
text-align: left;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 400px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cached-pages h2 {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
color: var(--color-accent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.cached-pages ul {
|
.cached-pages ul {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cached-pages li {
|
.cached-pages li {
|
||||||
@ -113,37 +184,126 @@
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border-bottom: 1px solid var(--color-accent);
|
border-bottom: 1px solid var(--color-accent);
|
||||||
padding-bottom: 2px;
|
padding-bottom: 2px;
|
||||||
|
display: inline-block;
|
||||||
|
transition: color 0.2s, transform 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cached-pages a:hover {
|
.cached-pages a:hover {
|
||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
|
transform: translateX(3px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background-color: rgba(251, 73, 52, 0.2);
|
||||||
|
color: #fb4934;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.online {
|
||||||
|
background-color: rgba(184, 187, 38, 0.2);
|
||||||
|
color: #b8bb26;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator-icon {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
margin-top: 3rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a {
|
||||||
|
color: var(--color-accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0% {
|
0% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
opacity: 0.6;
|
opacity: 0.7;
|
||||||
|
transform: scale(0.95);
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-shadow {
|
||||||
|
0% {
|
||||||
|
opacity: 0.7;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.3;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0.7;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cached-pages ul {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reduced motion preference */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.offline-icon,
|
||||||
|
.offline-icon::after,
|
||||||
|
.button,
|
||||||
|
.cached-pages a {
|
||||||
|
animation: none;
|
||||||
|
transition: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
<div class="offline-icon">📶</div>
|
<div class="status-indicator">
|
||||||
<h1>You're Offline</h1>
|
<span class="status-indicator-icon">⚠️</span>
|
||||||
<p>It looks like you've lost your internet connection. Some pages may still be available if you've visited them before.</p>
|
<span>You are currently offline</span>
|
||||||
<button class="button" id="retry-button">Retry Connection</button>
|
</div>
|
||||||
|
|
||||||
<div class="cached-pages" id="cached-pages">
|
<div class="offline-icon">📶</div>
|
||||||
|
<h1>No Internet Connection</h1>
|
||||||
|
<p>It looks like you've lost your internet connection. Some pages may still be available if you've visited them before.</p>
|
||||||
|
|
||||||
|
<button class="button" id="retry-button">
|
||||||
|
<span class="button-icon">🔄</span>
|
||||||
|
<span>Retry Connection</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="card" id="cached-pages">
|
||||||
<h2>Available Pages</h2>
|
<h2>Available Pages</h2>
|
||||||
<p>Loading cached pages...</p>
|
<p>Loading cached pages...</p>
|
||||||
<ul id="cached-pages-list"></ul>
|
<ul id="cached-pages-list"></ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p>© 2025 <a href="/">Justin Deal</a> | <a href="javascript:void(0)" id="clear-cache-button">Clear Cache</a></p>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@ -154,13 +314,35 @@
|
|||||||
|
|
||||||
// Update UI based on connection status
|
// Update UI based on connection status
|
||||||
function updateConnectionStatus() {
|
function updateConnectionStatus() {
|
||||||
|
const statusIndicator = document.querySelector('.status-indicator');
|
||||||
|
const statusIcon = document.querySelector('.status-indicator-icon');
|
||||||
|
const statusText = statusIndicator.querySelector('span:last-child');
|
||||||
|
|
||||||
if (checkConnection()) {
|
if (checkConnection()) {
|
||||||
// We're back online, reload the page
|
// We're back online
|
||||||
window.location.reload();
|
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 {
|
} else {
|
||||||
// Still offline
|
// Still offline
|
||||||
|
statusIndicator.classList.remove('online');
|
||||||
|
statusIcon.textContent = '⚠️';
|
||||||
|
statusText.textContent = 'You are currently offline';
|
||||||
|
|
||||||
document.querySelector('.offline-icon').textContent = '📶';
|
document.querySelector('.offline-icon').textContent = '📶';
|
||||||
document.querySelector('h1').textContent = "You're Offline";
|
document.querySelector('h1').textContent = "No Internet Connection";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -168,85 +350,153 @@
|
|||||||
window.addEventListener('online', updateConnectionStatus);
|
window.addEventListener('online', updateConnectionStatus);
|
||||||
window.addEventListener('offline', updateConnectionStatus);
|
window.addEventListener('offline', updateConnectionStatus);
|
||||||
|
|
||||||
|
// Initial status check
|
||||||
|
updateConnectionStatus();
|
||||||
|
|
||||||
// Retry button
|
// Retry button
|
||||||
document.getElementById('retry-button').addEventListener('click', () => {
|
document.getElementById('retry-button').addEventListener('click', () => {
|
||||||
|
const button = document.getElementById('retry-button');
|
||||||
|
button.innerHTML = '<span class="button-icon">🔄</span><span>Checking...</span>';
|
||||||
|
button.disabled = true;
|
||||||
|
|
||||||
document.querySelector('.offline-icon').textContent = '🔄';
|
document.querySelector('.offline-icon').textContent = '🔄';
|
||||||
document.querySelector('h1').textContent = "Checking Connection...";
|
document.querySelector('h1').textContent = "Checking Connection...";
|
||||||
|
|
||||||
// Try to fetch the homepage
|
// Try to fetch the homepage
|
||||||
fetch('/')
|
fetch('/', { cache: 'no-store' })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
// If successful, we're online
|
// If successful, we're online
|
||||||
window.location.reload();
|
updateConnectionStatus();
|
||||||
|
button.disabled = false;
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// Still offline
|
// Still offline
|
||||||
updateConnectionStatus();
|
updateConnectionStatus();
|
||||||
|
button.disabled = false;
|
||||||
|
button.innerHTML = '<span class="button-icon">🔄</span><span>Retry Connection</span>';
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// List cached pages if service worker and caches are available
|
// Clear cache button
|
||||||
if ('caches' in window && 'serviceWorker' in navigator) {
|
document.getElementById('clear-cache-button').addEventListener('click', () => {
|
||||||
caches.open('justin-deal-v1')
|
if ('caches' in window) {
|
||||||
.then(cache => {
|
caches.keys().then(cacheNames => {
|
||||||
return cache.keys()
|
return Promise.all(
|
||||||
.then(requests => {
|
cacheNames.map(cacheName => {
|
||||||
const cachedPagesList = document.getElementById('cached-pages-list');
|
return caches.delete(cacheName);
|
||||||
|
})
|
||||||
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>';
|
}).then(() => {
|
||||||
return;
|
alert('Cache cleared successfully');
|
||||||
}
|
loadCachedPages(); // Refresh the cached pages list
|
||||||
|
}).catch(error => {
|
||||||
// Filter for HTML pages
|
console.error('Error clearing cache:', error);
|
||||||
const htmlRequests = requests.filter(request => {
|
alert('Failed to clear cache: ' + error.message);
|
||||||
const url = new URL(request.url);
|
|
||||||
return url.pathname === '/' ||
|
|
||||||
url.pathname.endsWith('.html') ||
|
|
||||||
!url.pathname.includes('.');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sort by URL
|
|
||||||
htmlRequests.sort((a, b) => {
|
|
||||||
return new URL(a.url).pathname.localeCompare(new URL(b.url).pathname);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create list items
|
|
||||||
const listItems = htmlRequests.map(request => {
|
|
||||||
const url = new URL(request.url);
|
|
||||||
let pageName = url.pathname === '/' ? 'Home' : url.pathname
|
|
||||||
.replace(/\/$/, '')
|
|
||||||
.replace(/^\//, '')
|
|
||||||
.replace(/\.html$/, '')
|
|
||||||
.split('/')
|
|
||||||
.pop()
|
|
||||||
.replace(/-/g, ' ');
|
|
||||||
|
|
||||||
// Capitalize first letter of each word
|
|
||||||
pageName = pageName
|
|
||||||
.split(' ')
|
|
||||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
|
||||||
.join(' ');
|
|
||||||
|
|
||||||
return `<li><a href="${url.pathname}">${pageName}</a></li>`;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (listItems.length === 0) {
|
|
||||||
document.getElementById('cached-pages').innerHTML = '<h2>No Cached Pages Available</h2><p>Try visiting some pages when you\'re back online.</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
cachedPagesList.innerHTML = listItems.join('');
|
|
||||||
document.querySelector('#cached-pages p').style.display = 'none';
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error accessing cache:', error);
|
|
||||||
document.getElementById('cached-pages').innerHTML = '<h2>Could Not Access Cache</h2><p>There was an error accessing the cached pages.</p>';
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
document.getElementById('cached-pages').innerHTML = '<h2>Cache Not Available</h2><p>Your browser does not support caching or service workers.</p>';
|
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');
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
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
|
||||||
|
htmlRequests.sort((a, b) => {
|
||||||
|
return new URL(a.url).pathname.localeCompare(new URL(b.url).pathname);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create list items
|
||||||
|
const listItems = htmlRequests.map(request => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
let pageName = url.pathname === '/' ? 'Home' : url.pathname
|
||||||
|
.replace(/\/$/, '')
|
||||||
|
.replace(/^\//, '')
|
||||||
|
.replace(/\.html$/, '')
|
||||||
|
.split('/')
|
||||||
|
.pop()
|
||||||
|
.replace(/-/g, ' ');
|
||||||
|
|
||||||
|
// Capitalize first letter of each word
|
||||||
|
pageName = pageName
|
||||||
|
.split(' ')
|
||||||
|
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
return `<li><a href="${url.pathname}">${pageName}</a></li>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (listItems.length === 0) {
|
||||||
|
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 => {
|
||||||
|
console.error('Error accessing cache:', error);
|
||||||
|
cachedPagesContainer.innerHTML = '<h2>Could Not Access Cache</h2><p>There was an error accessing the cached pages.</p>';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load cached pages on page load
|
||||||
|
loadCachedPages();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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
|
* Service Worker for justin.deal
|
||||||
* Provides caching and offline support
|
* Provides caching and offline support with advanced strategies
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const CACHE_NAME = 'justin-deal-v1';
|
// Cache versioning for easier updates
|
||||||
|
const CACHE_VERSION = '2';
|
||||||
|
const STATIC_CACHE_NAME = `justin-deal-static-v${CACHE_VERSION}`;
|
||||||
|
const DYNAMIC_CACHE_NAME = `justin-deal-dynamic-v${CACHE_VERSION}`;
|
||||||
|
const API_CACHE_NAME = `justin-deal-api-v${CACHE_VERSION}`;
|
||||||
|
const IMAGE_CACHE_NAME = `justin-deal-images-v${CACHE_VERSION}`;
|
||||||
|
|
||||||
|
// Cache expiration times (in milliseconds)
|
||||||
|
const CACHE_EXPIRATION = {
|
||||||
|
API: 5 * 60 * 1000, // 5 minutes
|
||||||
|
DYNAMIC: 24 * 60 * 60 * 1000, // 1 day
|
||||||
|
IMAGES: 7 * 24 * 60 * 60 * 1000 // 7 days
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cache size limits
|
||||||
|
const CACHE_SIZE_LIMITS = {
|
||||||
|
DYNAMIC: 50, // items
|
||||||
|
IMAGES: 100 // items
|
||||||
|
};
|
||||||
|
|
||||||
// Assets to cache immediately on service worker install
|
// Assets to cache immediately on service worker install
|
||||||
const PRECACHE_ASSETS = [
|
const PRECACHE_ASSETS = [
|
||||||
@ -11,6 +29,7 @@ const PRECACHE_ASSETS = [
|
|||||||
'/index.html',
|
'/index.html',
|
||||||
'/favicon.svg',
|
'/favicon.svg',
|
||||||
'/site.webmanifest',
|
'/site.webmanifest',
|
||||||
|
'/offline.html',
|
||||||
'/favicons/favicon.png',
|
'/favicons/favicon.png',
|
||||||
'/favicons/apple-touch-icon.png',
|
'/favicons/apple-touch-icon.png',
|
||||||
'/favicons/favicon-16x16.png',
|
'/favicons/favicon-16x16.png',
|
||||||
@ -36,40 +55,110 @@ const ROUTE_STRATEGIES = [
|
|||||||
// HTML pages - network first
|
// HTML pages - network first
|
||||||
{
|
{
|
||||||
pattern: /\.html$|\/$/,
|
pattern: /\.html$|\/$/,
|
||||||
strategy: CACHE_STRATEGIES.NETWORK_FIRST
|
strategy: CACHE_STRATEGIES.NETWORK_FIRST,
|
||||||
|
cacheName: STATIC_CACHE_NAME
|
||||||
},
|
},
|
||||||
// CSS and JS - stale while revalidate
|
// CSS and JS - stale while revalidate
|
||||||
{
|
{
|
||||||
pattern: /\.(css|js)$/,
|
pattern: /\.(css|js)$/,
|
||||||
strategy: CACHE_STRATEGIES.STALE_WHILE_REVALIDATE
|
strategy: CACHE_STRATEGIES.STALE_WHILE_REVALIDATE,
|
||||||
|
cacheName: STATIC_CACHE_NAME
|
||||||
},
|
},
|
||||||
// Images - cache first
|
// Images - cache first
|
||||||
{
|
{
|
||||||
pattern: /\.(jpe?g|png|gif|svg|webp|avif)$/,
|
pattern: /\.(jpe?g|png|gif|svg|webp|avif)$/,
|
||||||
strategy: CACHE_STRATEGIES.CACHE_FIRST
|
strategy: CACHE_STRATEGIES.CACHE_FIRST,
|
||||||
|
cacheName: IMAGE_CACHE_NAME
|
||||||
},
|
},
|
||||||
// Fonts - cache first
|
// Fonts - cache first
|
||||||
{
|
{
|
||||||
pattern: /\.(woff2?|ttf|otf|eot)$/,
|
pattern: /\.(woff2?|ttf|otf|eot)$/,
|
||||||
strategy: CACHE_STRATEGIES.CACHE_FIRST
|
strategy: CACHE_STRATEGIES.CACHE_FIRST,
|
||||||
|
cacheName: STATIC_CACHE_NAME
|
||||||
},
|
},
|
||||||
// API requests - network first
|
// API requests - stale while revalidate
|
||||||
{
|
{
|
||||||
pattern: /\/api\//,
|
pattern: /\/api\//,
|
||||||
strategy: CACHE_STRATEGIES.NETWORK_FIRST
|
strategy: CACHE_STRATEGIES.STALE_WHILE_REVALIDATE,
|
||||||
|
cacheName: API_CACHE_NAME
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
// Determine cache strategy for a given URL
|
// Determine cache strategy and cache name for a given URL
|
||||||
function getStrategyForUrl(url) {
|
function getStrategyForUrl(url) {
|
||||||
const matchedRoute = ROUTE_STRATEGIES.find(route => route.pattern.test(url));
|
const matchedRoute = ROUTE_STRATEGIES.find(route => route.pattern.test(url));
|
||||||
return matchedRoute ? matchedRoute.strategy : CACHE_STRATEGIES.NETWORK_FIRST;
|
return {
|
||||||
|
strategy: matchedRoute ? matchedRoute.strategy : CACHE_STRATEGIES.NETWORK_FIRST,
|
||||||
|
cacheName: matchedRoute ? matchedRoute.cacheName : DYNAMIC_CACHE_NAME
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to store cache metadata
|
||||||
|
function storeCacheMetadata(cacheName, url, metadata) {
|
||||||
|
return caches.open(`${cacheName}-metadata`)
|
||||||
|
.then(metaCache => {
|
||||||
|
return metaCache.put(
|
||||||
|
new Request(`metadata:${url}`),
|
||||||
|
new Response(JSON.stringify(metadata))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get cache metadata
|
||||||
|
async function getCacheMetadata(cacheName, url) {
|
||||||
|
const metaCache = await caches.open(`${cacheName}-metadata`);
|
||||||
|
const metadataResponse = await metaCache.match(new Request(`metadata:${url}`));
|
||||||
|
|
||||||
|
if (metadataResponse) {
|
||||||
|
return JSON.parse(await metadataResponse.text());
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to check if a cached response is expired
|
||||||
|
async function isCacheExpired(cacheName, url) {
|
||||||
|
const metadata = await getCacheMetadata(cacheName, url);
|
||||||
|
|
||||||
|
if (!metadata || !metadata.timestamp) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const age = Date.now() - metadata.timestamp;
|
||||||
|
|
||||||
|
switch (cacheName) {
|
||||||
|
case API_CACHE_NAME:
|
||||||
|
return age > CACHE_EXPIRATION.API;
|
||||||
|
case DYNAMIC_CACHE_NAME:
|
||||||
|
return age > CACHE_EXPIRATION.DYNAMIC;
|
||||||
|
case IMAGE_CACHE_NAME:
|
||||||
|
return age > CACHE_EXPIRATION.IMAGES;
|
||||||
|
default:
|
||||||
|
return false; // Static cache doesn't expire
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to limit cache size
|
||||||
|
async function trimCache(cacheName, maxItems) {
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
const keys = await cache.keys();
|
||||||
|
|
||||||
|
if (keys.length > maxItems) {
|
||||||
|
// Delete oldest items (first in the list)
|
||||||
|
for (let i = 0; i < keys.length - maxItems; i++) {
|
||||||
|
await cache.delete(keys[i]);
|
||||||
|
|
||||||
|
// Also delete metadata
|
||||||
|
const metaCache = await caches.open(`${cacheName}-metadata`);
|
||||||
|
await metaCache.delete(new Request(`metadata:${keys[i].url}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Install event - precache critical assets
|
// Install event - precache critical assets
|
||||||
self.addEventListener('install', event => {
|
self.addEventListener('install', event => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.open(CACHE_NAME)
|
caches.open(STATIC_CACHE_NAME)
|
||||||
.then(cache => cache.addAll(PRECACHE_ASSETS))
|
.then(cache => cache.addAll(PRECACHE_ASSETS))
|
||||||
.then(() => self.skipWaiting())
|
.then(() => self.skipWaiting())
|
||||||
);
|
);
|
||||||
@ -81,7 +170,11 @@ self.addEventListener('activate', event => {
|
|||||||
caches.keys().then(cacheNames => {
|
caches.keys().then(cacheNames => {
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
cacheNames
|
cacheNames
|
||||||
.filter(cacheName => cacheName !== CACHE_NAME)
|
.filter(cacheName => {
|
||||||
|
// Keep current version caches
|
||||||
|
return !cacheName.includes(`-v${CACHE_VERSION}`) &&
|
||||||
|
!cacheName.includes(`-metadata`);
|
||||||
|
})
|
||||||
.map(cacheName => caches.delete(cacheName))
|
.map(cacheName => caches.delete(cacheName))
|
||||||
);
|
);
|
||||||
}).then(() => self.clients.claim())
|
}).then(() => self.clients.claim())
|
||||||
@ -89,15 +182,21 @@ self.addEventListener('activate', event => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Helper function to handle network-first strategy
|
// Helper function to handle network-first strategy
|
||||||
async function networkFirstStrategy(request) {
|
async function networkFirstStrategy(request, cacheName) {
|
||||||
try {
|
try {
|
||||||
// Try network first
|
// Try network first
|
||||||
const networkResponse = await fetch(request);
|
const networkResponse = await fetch(request);
|
||||||
|
|
||||||
// If successful, clone and cache the response
|
// If successful, clone and cache the response
|
||||||
if (networkResponse.ok) {
|
if (networkResponse.ok) {
|
||||||
const cache = await caches.open(CACHE_NAME);
|
const cache = await caches.open(cacheName);
|
||||||
cache.put(request, networkResponse.clone());
|
cache.put(request, networkResponse.clone());
|
||||||
|
|
||||||
|
// Store metadata with timestamp
|
||||||
|
storeCacheMetadata(cacheName, request.url, {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
url: request.url
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return networkResponse;
|
return networkResponse;
|
||||||
@ -114,45 +213,104 @@ async function networkFirstStrategy(request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to handle cache-first strategy
|
// Helper function to handle cache-first strategy
|
||||||
async function cacheFirstStrategy(request) {
|
async function cacheFirstStrategy(request, cacheName) {
|
||||||
// Try cache first
|
// Try cache first
|
||||||
const cachedResponse = await caches.match(request);
|
const cachedResponse = await caches.match(request);
|
||||||
if (cachedResponse) {
|
|
||||||
|
// Check if we have a cached response and if it's expired
|
||||||
|
const isExpired = cachedResponse ? await isCacheExpired(cacheName, request.url) : true;
|
||||||
|
|
||||||
|
// If we have a valid cached response, use it
|
||||||
|
if (cachedResponse && !isExpired) {
|
||||||
return cachedResponse;
|
return cachedResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If not in cache, get from network
|
// If not in cache or expired, get from network
|
||||||
const networkResponse = await fetch(request);
|
try {
|
||||||
|
const networkResponse = await fetch(request);
|
||||||
|
|
||||||
// Cache the response for future
|
// Cache the response for future
|
||||||
if (networkResponse.ok) {
|
if (networkResponse.ok) {
|
||||||
const cache = await caches.open(CACHE_NAME);
|
const cache = await caches.open(cacheName);
|
||||||
cache.put(request, networkResponse.clone());
|
cache.put(request, networkResponse.clone());
|
||||||
|
|
||||||
|
// Store metadata with timestamp
|
||||||
|
storeCacheMetadata(cacheName, request.url, {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
url: request.url
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trim cache if needed
|
||||||
|
if (cacheName === IMAGE_CACHE_NAME) {
|
||||||
|
trimCache(IMAGE_CACHE_NAME, CACHE_SIZE_LIMITS.IMAGES);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return networkResponse;
|
||||||
|
} 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no cache, throw error
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
return networkResponse;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to handle stale-while-revalidate strategy
|
// Enhanced stale-while-revalidate strategy with cache expiration and metadata
|
||||||
async function staleWhileRevalidateStrategy(request) {
|
async function staleWhileRevalidateStrategy(request, cacheName) {
|
||||||
|
const url = request.url;
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
|
||||||
// Try to get from cache
|
// Try to get from cache
|
||||||
const cachedResponse = await caches.match(request);
|
const cachedResponse = await cache.match(request);
|
||||||
|
|
||||||
|
// Check if we have a cached response and if it's expired
|
||||||
|
const isExpired = cachedResponse ? await isCacheExpired(cacheName, url) : true;
|
||||||
|
|
||||||
|
// If we have a cached response, use it immediately (even if expired)
|
||||||
|
// This is the "stale" part
|
||||||
|
|
||||||
// Fetch from network to update cache (don't await)
|
// Fetch from network to update cache (don't await)
|
||||||
const fetchPromise = fetch(request)
|
const fetchPromise = fetch(request)
|
||||||
.then(networkResponse => {
|
.then(networkResponse => {
|
||||||
if (networkResponse.ok) {
|
if (networkResponse.ok) {
|
||||||
const cache = caches.open(CACHE_NAME);
|
// Clone the response before using it
|
||||||
cache.then(cache => cache.put(request, networkResponse.clone()));
|
const responseToCache = networkResponse.clone();
|
||||||
|
|
||||||
|
// Store in cache
|
||||||
|
cache.put(request, responseToCache);
|
||||||
|
|
||||||
|
// Store metadata with timestamp
|
||||||
|
storeCacheMetadata(cacheName, url, {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
url: url
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trim cache if needed
|
||||||
|
if (cacheName === DYNAMIC_CACHE_NAME) {
|
||||||
|
trimCache(DYNAMIC_CACHE_NAME, CACHE_SIZE_LIMITS.DYNAMIC);
|
||||||
|
} else if (cacheName === IMAGE_CACHE_NAME) {
|
||||||
|
trimCache(IMAGE_CACHE_NAME, CACHE_SIZE_LIMITS.IMAGES);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return networkResponse;
|
return networkResponse;
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.error('Failed to fetch and update cache:', error);
|
console.error('Failed to fetch and update cache:', error);
|
||||||
|
// If we have a cached response but it's expired, still return it
|
||||||
|
if (cachedResponse) {
|
||||||
|
console.log('Returning expired cached response as fallback');
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Return cached response immediately if available
|
// Return cached response immediately if available and not expired
|
||||||
return cachedResponse || fetchPromise;
|
// Otherwise wait for the network response
|
||||||
|
return cachedResponse && !isExpired ? cachedResponse : fetchPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch event - handle all fetch requests
|
// Fetch event - handle all fetch requests
|
||||||
@ -164,21 +322,21 @@ self.addEventListener('fetch', event => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the appropriate strategy for this URL
|
// Get the appropriate strategy and cache name for this URL
|
||||||
const strategy = getStrategyForUrl(event.request.url);
|
const { strategy, cacheName } = getStrategyForUrl(event.request.url);
|
||||||
|
|
||||||
// Apply the selected strategy
|
// Apply the selected strategy
|
||||||
switch (strategy) {
|
switch (strategy) {
|
||||||
case CACHE_STRATEGIES.NETWORK_FIRST:
|
case CACHE_STRATEGIES.NETWORK_FIRST:
|
||||||
event.respondWith(networkFirstStrategy(event.request));
|
event.respondWith(networkFirstStrategy(event.request, cacheName));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case CACHE_STRATEGIES.CACHE_FIRST:
|
case CACHE_STRATEGIES.CACHE_FIRST:
|
||||||
event.respondWith(cacheFirstStrategy(event.request));
|
event.respondWith(cacheFirstStrategy(event.request, cacheName));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case CACHE_STRATEGIES.STALE_WHILE_REVALIDATE:
|
case CACHE_STRATEGIES.STALE_WHILE_REVALIDATE:
|
||||||
event.respondWith(staleWhileRevalidateStrategy(event.request));
|
event.respondWith(staleWhileRevalidateStrategy(event.request, cacheName));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case CACHE_STRATEGIES.CACHE_ONLY:
|
case CACHE_STRATEGIES.CACHE_ONLY:
|
||||||
@ -191,7 +349,7 @@ self.addEventListener('fetch', event => {
|
|||||||
|
|
||||||
default:
|
default:
|
||||||
// Default to network first
|
// Default to network first
|
||||||
event.respondWith(networkFirstStrategy(event.request));
|
event.respondWith(networkFirstStrategy(event.request, DYNAMIC_CACHE_NAME));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -222,4 +380,21 @@ self.addEventListener('message', event => {
|
|||||||
if (event.data && event.data.type === 'SKIP_WAITING') {
|
if (event.data && event.data.type === 'SKIP_WAITING') {
|
||||||
self.skipWaiting();
|
self.skipWaiting();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle cache cleanup request
|
||||||
|
if (event.data && event.data.type === 'CLEAR_CACHES') {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then(cacheNames => {
|
||||||
|
return Promise.all(
|
||||||
|
cacheNames.map(cacheName => caches.delete(cacheName))
|
||||||
|
);
|
||||||
|
}).then(() => {
|
||||||
|
// Notify client that caches were cleared
|
||||||
|
event.ports[0].postMessage({
|
||||||
|
status: 'success',
|
||||||
|
message: 'All caches cleared successfully'
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -1,66 +1,56 @@
|
|||||||
<script>
|
<script is:inline>
|
||||||
// This script extends the existing Alpine.js functionality for style controls
|
// This script adds keyboard shortcuts for style controls
|
||||||
// by adding keyboard shortcuts
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Add keyboard shortcut listener
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
// Only process if not in an input field
|
||||||
|
const target = e.target;
|
||||||
|
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// TypeScript declarations for Alpine.js
|
// Find Alpine components that might have style controls
|
||||||
declare const Alpine: {
|
const styleControlsElements = document.querySelectorAll('[x-data="styleControls"]');
|
||||||
data: (name: string, callback?: () => any) => any;
|
const searchServicesElements = document.querySelectorAll('[x-data="searchServices"]');
|
||||||
};
|
|
||||||
document.addEventListener('alpine:init', () => {
|
|
||||||
// Extend the existing styleControls Alpine data
|
|
||||||
const originalStyleControls = Alpine.data('styleControls');
|
|
||||||
|
|
||||||
Alpine.data('styleControls', () => {
|
// Function to find a component with the method we need
|
||||||
// Get the original data object
|
const findComponentWithMethod = (elements, methodName) => {
|
||||||
const original = typeof originalStyleControls === 'function'
|
for (const el of elements) {
|
||||||
? originalStyleControls()
|
if (el && el.__x && el.__x.$data && typeof el.__x.$data[methodName] === 'function') {
|
||||||
: {};
|
return el.__x.$data;
|
||||||
|
|
||||||
// Return extended object with our additions
|
|
||||||
return {
|
|
||||||
// Spread original properties and methods
|
|
||||||
...original,
|
|
||||||
|
|
||||||
// Override init to add keyboard shortcuts
|
|
||||||
init() {
|
|
||||||
// Call original init if it exists
|
|
||||||
if (original.init) {
|
|
||||||
original.init.call(this);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add keyboard shortcut listener
|
|
||||||
document.addEventListener('keydown', (e) => {
|
|
||||||
// Only process if not in an input field
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Alt+1, Alt+2, Alt+3 for icon sizes
|
|
||||||
if (e.altKey && e.key === '1' && this.setIconSize) {
|
|
||||||
this.setIconSize('small');
|
|
||||||
} else if (e.altKey && e.key === '2' && this.setIconSize) {
|
|
||||||
this.setIconSize('medium');
|
|
||||||
} else if (e.altKey && e.key === '3' && this.setIconSize) {
|
|
||||||
this.setIconSize('large');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Alt+G to toggle grid/list view
|
|
||||||
if (e.altKey && e.key === 'g' && this.toggleViewMode) {
|
|
||||||
this.toggleViewMode();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Alt+B, Alt+I, Alt+N for display modes
|
|
||||||
if (e.altKey && e.key === 'b' && this.setDisplayMode) {
|
|
||||||
this.setDisplayMode('both');
|
|
||||||
} else if (e.altKey && e.key === 'i' && this.setDisplayMode) {
|
|
||||||
this.setDisplayMode('image');
|
|
||||||
} else if (e.altKey && e.key === 'n' && this.setDisplayMode) {
|
|
||||||
this.setDisplayMode('name');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
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' && 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' && styleComponent.toggleViewMode) {
|
||||||
|
styleComponent.toggleViewMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alt+B, Alt+I, Alt+N for display modes
|
||||||
|
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>
|
</script>
|
||||||
|
@ -17,58 +17,47 @@ const {
|
|||||||
tag: Tag = 'div'
|
tag: Tag = 'div'
|
||||||
} = Astro.props;
|
} = Astro.props;
|
||||||
|
|
||||||
const animationClasses = {
|
// Validate animation type
|
||||||
'fade': 'animate-fade',
|
const validAnimations = ['fade', 'scale', 'slide-up', 'slide-down', 'slide-left', 'slide-right', 'pulse'];
|
||||||
'scale': 'animate-scale',
|
const animationClass = validAnimations.includes(animation) ? `animate-${animation}` : '';
|
||||||
'slide-up': 'animate-slide-up',
|
|
||||||
'slide-down': 'animate-slide-down',
|
|
||||||
'slide-left': 'animate-slide-left',
|
|
||||||
'slide-right': 'animate-slide-right',
|
|
||||||
'pulse': 'animate-pulse'
|
|
||||||
};
|
|
||||||
|
|
||||||
const animationClass = animationClasses[animation] || '';
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<Tag class:list={[animationClass, className]}>
|
<Tag class:list={[animationClass, className]}>
|
||||||
<style define:vars={{
|
<div
|
||||||
animationDuration: `${duration}ms`,
|
class="animation-container"
|
||||||
animationDelay: `${delay}ms`,
|
style={`--animation-duration: ${duration}ms; --animation-delay: ${delay}ms; --animation-easing: ${easing};`}
|
||||||
animationEasing: easing
|
>
|
||||||
}}>
|
<slot />
|
||||||
.animate-fade {
|
</div>
|
||||||
animation: fade var(--animationDuration) var(--animationEasing) var(--animationDelay) both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-scale {
|
|
||||||
animation: scale var(--animationDuration) var(--animationEasing) var(--animationDelay) both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-slide-up {
|
|
||||||
animation: slideUp var(--animationDuration) var(--animationEasing) var(--animationDelay) both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-slide-down {
|
|
||||||
animation: slideDown var(--animationDuration) var(--animationEasing) var(--animationDelay) both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-slide-left {
|
|
||||||
animation: slideLeft var(--animationDuration) var(--animationEasing) var(--animationDelay) both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-slide-right {
|
|
||||||
animation: slideRight var(--animationDuration) var(--animationEasing) var(--animationDelay) both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-pulse {
|
|
||||||
animation: pulse var(--animationDuration) var(--animationEasing) var(--animationDelay) infinite;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<slot />
|
|
||||||
</Tag>
|
</Tag>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
/* Animation container to apply styles */
|
||||||
|
.animation-container {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base animation properties */
|
||||||
|
[class^="animate-"] .animation-container {
|
||||||
|
animation-duration: var(--animation-duration);
|
||||||
|
animation-timing-function: var(--animation-easing);
|
||||||
|
animation-delay: var(--animation-delay);
|
||||||
|
animation-fill-mode: both;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation types */
|
||||||
|
.animate-fade { animation-name: fade; }
|
||||||
|
.animate-scale { animation-name: scale; }
|
||||||
|
.animate-slide-up { animation-name: slideUp; }
|
||||||
|
.animate-slide-down { animation-name: slideDown; }
|
||||||
|
.animate-slide-left { animation-name: slideLeft; }
|
||||||
|
.animate-slide-right { animation-name: slideRight; }
|
||||||
|
.animate-pulse {
|
||||||
|
animation-name: pulse;
|
||||||
|
animation-iteration-count: infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keyframes definitions */
|
||||||
@keyframes fade {
|
@keyframes fade {
|
||||||
from { opacity: 0; }
|
from { opacity: 0; }
|
||||||
to { opacity: 1; }
|
to { opacity: 1; }
|
||||||
@ -105,10 +94,9 @@ const animationClass = animationClasses[animation] || '';
|
|||||||
100% { transform: scale(1); }
|
100% { transform: scale(1); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Respect reduced motion preferences */
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.animate-fade, .animate-scale, .animate-slide-up,
|
[class^="animate-"] {
|
||||||
.animate-slide-down, .animate-slide-left,
|
|
||||||
.animate-slide-right, .animate-pulse {
|
|
||||||
animation: none !important;
|
animation: none !important;
|
||||||
transition: none !important;
|
transition: none !important;
|
||||||
}
|
}
|
||||||
|
@ -13,41 +13,71 @@ const {
|
|||||||
class: className = '',
|
class: className = '',
|
||||||
} = Astro.props;
|
} = Astro.props;
|
||||||
|
|
||||||
const sizeMap = {
|
// Map sizes to CSS classes
|
||||||
small: 'w-4 h-4',
|
const sizeClasses = {
|
||||||
medium: 'w-8 h-8',
|
small: 'size-sm',
|
||||||
large: 'w-12 h-12',
|
medium: 'size-md',
|
||||||
|
large: 'size-lg'
|
||||||
};
|
};
|
||||||
|
|
||||||
const sizeClass = sizeMap[size] || sizeMap.medium;
|
const sizeClass = sizeClasses[size] || sizeClasses.medium;
|
||||||
---
|
---
|
||||||
|
|
||||||
{type === 'spinner' && (
|
<div
|
||||||
<div class:list={['loading-spinner', sizeClass, className]} style={{ '--spinner-color': color }}>
|
class:list={['loading-indicator', `loading-${type}`, sizeClass, className]}
|
||||||
<div class="spinner-ring"></div>
|
style={`--indicator-color: ${color};`}
|
||||||
<div class="spinner-ring"></div>
|
role="status"
|
||||||
<div class="spinner-ring"></div>
|
aria-label="Loading"
|
||||||
</div>
|
>
|
||||||
)}
|
{type === 'spinner' && (
|
||||||
|
<>
|
||||||
|
<div class="spinner-ring"></div>
|
||||||
|
<div class="spinner-ring"></div>
|
||||||
|
<div class="spinner-ring"></div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{type === 'dots' && (
|
{type === 'dots' && (
|
||||||
<div class:list={['loading-dots', sizeClass, className]} style={{ '--dots-color': color }}>
|
<>
|
||||||
<div class="dot"></div>
|
<div class="dot"></div>
|
||||||
<div class="dot"></div>
|
<div class="dot"></div>
|
||||||
<div class="dot"></div>
|
<div class="dot"></div>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{type === 'pulse' && (
|
{type === 'pulse' && (
|
||||||
<div class:list={['loading-pulse', sizeClass, className]} style={{ '--pulse-color': color }}>
|
<div class="pulse-element"></div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* Spinner animation */
|
/* Base styles */
|
||||||
|
.loading-indicator {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--indicator-color, currentColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Size variations */
|
||||||
|
.size-sm {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.size-md {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.size-lg {
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spinner type */
|
||||||
.loading-spinner {
|
.loading-spinner {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-block;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.spinner-ring {
|
.spinner-ring {
|
||||||
@ -57,7 +87,7 @@ const sizeClass = sizeMap[size] || sizeMap.medium;
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border: 2px solid transparent;
|
border: 2px solid transparent;
|
||||||
border-top-color: var(--spinner-color, currentColor);
|
border-top-color: var(--indicator-color, currentColor);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 1s linear infinite;
|
animation: spin 1s linear infinite;
|
||||||
}
|
}
|
||||||
@ -75,18 +105,15 @@ const sizeClass = sizeMap[size] || sizeMap.medium;
|
|||||||
100% { transform: rotate(360deg); }
|
100% { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dots animation */
|
/* Dots type */
|
||||||
.loading-dots {
|
.loading-dots {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-dots .dot {
|
.loading-dots .dot {
|
||||||
width: 25%;
|
width: 25%;
|
||||||
height: 25%;
|
height: 25%;
|
||||||
background-color: var(--dots-color, currentColor);
|
background-color: var(--indicator-color, currentColor);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: dotBounce 1.4s infinite ease-in-out both;
|
animation: dotBounce 1.4s infinite ease-in-out both;
|
||||||
}
|
}
|
||||||
@ -104,9 +131,15 @@ const sizeClass = sizeMap[size] || sizeMap.medium;
|
|||||||
40% { transform: scale(1); }
|
40% { transform: scale(1); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Pulse animation */
|
/* Pulse type */
|
||||||
.loading-pulse {
|
.loading-pulse {
|
||||||
background-color: var(--pulse-color, currentColor);
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-pulse .pulse-element {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--indicator-color, currentColor);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: pulse 1.5s ease-in-out infinite;
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
@ -119,8 +152,12 @@ const sizeClass = sizeMap[size] || sizeMap.medium;
|
|||||||
|
|
||||||
/* Respect reduced motion preferences */
|
/* Respect reduced motion preferences */
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.spinner-ring, .loading-dots .dot, .loading-pulse {
|
.spinner-ring, .dot, .pulse-element {
|
||||||
animation: none;
|
animation: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse-element {
|
||||||
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -18,6 +18,8 @@ const {
|
|||||||
class={`loading-overlay fixed inset-0 flex flex-col items-center justify-center z-50 bg-opacity-90 zag-bg ${className}`}
|
class={`loading-overlay fixed inset-0 flex flex-col items-center justify-center z-50 bg-opacity-90 zag-bg ${className}`}
|
||||||
role="alert"
|
role="alert"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
|
aria-atomic="true"
|
||||||
|
data-loading-overlay
|
||||||
>
|
>
|
||||||
<div class="loading-content text-center p-8 rounded-lg">
|
<div class="loading-content text-center p-8 rounded-lg">
|
||||||
<!-- Enhanced loading indicator -->
|
<!-- Enhanced loading indicator -->
|
||||||
@ -73,15 +75,87 @@ const {
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// This script will be executed client-side
|
// Enhanced loading manager with Intersection Observer and performance optimizations
|
||||||
class LoadingManager {
|
class LoadingManager {
|
||||||
overlay: HTMLElement | null = null;
|
overlay: HTMLElement | null = null;
|
||||||
timeoutId: ReturnType<typeof setTimeout> | null = null;
|
timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
longLoadingTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
longLoadingTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
loadingElements: Set<Element> = new Set();
|
||||||
|
observer: IntersectionObserver | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.overlay = document.querySelector('.loading-overlay');
|
this.overlay = document.querySelector('[data-loading-overlay]');
|
||||||
this.setupNavigationListeners();
|
this.setupNavigationListeners();
|
||||||
|
this.setupIntersectionObserver();
|
||||||
|
this.observeLoadingElements();
|
||||||
|
}
|
||||||
|
|
||||||
|
setupIntersectionObserver() {
|
||||||
|
// Create an intersection observer to detect when loading elements are in the viewport
|
||||||
|
this.observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach(entry => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
// Element is in viewport, add to loading elements set
|
||||||
|
this.loadingElements.add(entry.target);
|
||||||
|
this.updateLoadingState();
|
||||||
|
} else {
|
||||||
|
// Element is out of viewport, remove from loading elements set
|
||||||
|
this.loadingElements.delete(entry.target);
|
||||||
|
this.updateLoadingState();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{
|
||||||
|
threshold: 0.1, // 10% visibility threshold
|
||||||
|
rootMargin: '100px' // Start loading slightly before elements come into view
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
observeLoadingElements() {
|
||||||
|
// Observe all elements with loading-element class
|
||||||
|
document.querySelectorAll('[data-loading="true"]').forEach(el => {
|
||||||
|
if (this.observer) {
|
||||||
|
this.observer.observe(el);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up a mutation observer to detect new loading elements
|
||||||
|
const mutationObserver = new MutationObserver((mutations) => {
|
||||||
|
mutations.forEach(mutation => {
|
||||||
|
if (mutation.type === 'attributes' && mutation.attributeName === 'data-loading') {
|
||||||
|
const target = mutation.target as Element;
|
||||||
|
if (target.getAttribute('data-loading') === 'true') {
|
||||||
|
if (this.observer) {
|
||||||
|
this.observer.observe(target);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (this.observer) {
|
||||||
|
this.observer.unobserve(target);
|
||||||
|
}
|
||||||
|
this.loadingElements.delete(target);
|
||||||
|
this.updateLoadingState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Observe the entire document for changes to data-loading attribute
|
||||||
|
mutationObserver.observe(document.body, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['data-loading'],
|
||||||
|
subtree: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLoadingState() {
|
||||||
|
// If there are loading elements in the viewport, show loading state
|
||||||
|
if (this.loadingElements.size > 0) {
|
||||||
|
this.showLoading();
|
||||||
|
} else {
|
||||||
|
this.hideLoading();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setupNavigationListeners() {
|
setupNavigationListeners() {
|
||||||
@ -113,6 +187,8 @@ const {
|
|||||||
// For SPA navigation (if using client-side routing)
|
// For SPA navigation (if using client-side routing)
|
||||||
document.addEventListener('astro:page-load', () => {
|
document.addEventListener('astro:page-load', () => {
|
||||||
this.hideLoading();
|
this.hideLoading();
|
||||||
|
// Re-observe loading elements after page load
|
||||||
|
this.observeLoadingElements();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -123,10 +199,15 @@ const {
|
|||||||
|
|
||||||
document.addEventListener('astro:after-swap', () => {
|
document.addEventListener('astro:after-swap', () => {
|
||||||
this.hideLoading();
|
this.hideLoading();
|
||||||
|
// Re-observe loading elements after view transition
|
||||||
|
this.observeLoadingElements();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
showLoading() {
|
showLoading() {
|
||||||
|
// Use requestIdleCallback for non-critical UI updates if available
|
||||||
|
const scheduleUpdate = window.requestIdleCallback || window.requestAnimationFrame || setTimeout;
|
||||||
|
|
||||||
// Clear any existing timeouts
|
// Clear any existing timeouts
|
||||||
if (this.timeoutId) {
|
if (this.timeoutId) {
|
||||||
clearTimeout(this.timeoutId);
|
clearTimeout(this.timeoutId);
|
||||||
@ -143,13 +224,15 @@ const {
|
|||||||
|
|
||||||
// Only show loading UI after a short delay to avoid flashing on fast loads
|
// Only show loading UI after a short delay to avoid flashing on fast loads
|
||||||
this.timeoutId = setTimeout(() => {
|
this.timeoutId = setTimeout(() => {
|
||||||
// Set loading state in Alpine.js components if they exist
|
scheduleUpdate(() => {
|
||||||
document.querySelectorAll('[x-data]').forEach(el => {
|
// Set loading state in Alpine.js components if they exist
|
||||||
// @ts-ignore
|
document.querySelectorAll('[x-data]').forEach(el => {
|
||||||
if (el.__x && typeof el.__x.$data.loading !== 'undefined') {
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
el.__x.$data.loading = true;
|
if (el.__x && typeof el.__x.$data.loading !== 'undefined') {
|
||||||
}
|
// @ts-ignore
|
||||||
|
el.__x.$data.loading = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}, showDelay);
|
}, showDelay);
|
||||||
|
|
||||||
@ -216,10 +299,35 @@ const {
|
|||||||
// Default delay if Network Information API is not available
|
// Default delay if Network Information API is not available
|
||||||
return 100;
|
return 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up resources when the component is destroyed
|
||||||
|
destroy() {
|
||||||
|
if (this.observer) {
|
||||||
|
this.observer.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.timeoutId) {
|
||||||
|
clearTimeout(this.timeoutId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.longLoadingTimeoutId) {
|
||||||
|
clearTimeout(this.longLoadingTimeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize the loading manager when the DOM is ready
|
// Initialize the loading manager when the DOM is ready
|
||||||
|
let loadingManager: LoadingManager | null = null;
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
new LoadingManager();
|
loadingManager = new LoadingManager();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up on page unload
|
||||||
|
window.addEventListener('beforeunload', () => {
|
||||||
|
if (loadingManager) {
|
||||||
|
loadingManager.destroy();
|
||||||
|
loadingManager = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
@ -3,103 +3,92 @@ import { Image } from 'astro:assets';
|
|||||||
import type { ImageMetadata } from 'astro';
|
import type { ImageMetadata } from 'astro';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ResponsiveImage component with advanced features for optimal performance
|
* ResponsiveImage component provides enhanced image handling with:
|
||||||
|
* - Art direction support via <picture> element
|
||||||
|
* - Modern format support (AVIF, WebP)
|
||||||
|
* - Blur-up loading effect
|
||||||
|
* - Lazy loading with IntersectionObserver
|
||||||
|
* - Priority hints for important images
|
||||||
|
*
|
||||||
* @component
|
* @component
|
||||||
|
* @example
|
||||||
|
* ```astro
|
||||||
|
* <ResponsiveImage
|
||||||
|
* src={import('../assets/hero.jpg')}
|
||||||
|
* alt="Hero image"
|
||||||
|
* width={1200}
|
||||||
|
* height={600}
|
||||||
|
* priority={true}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
*/
|
*/
|
||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/** The image source (either imported via astro:assets or a URL string) */
|
||||||
* The image source (either an imported image or a URL)
|
|
||||||
*/
|
|
||||||
src: ImageMetadata | string;
|
src: ImageMetadata | string;
|
||||||
|
|
||||||
/**
|
/** Alternative text for the image (required for accessibility) */
|
||||||
* Alternative text for the image
|
|
||||||
*/
|
|
||||||
alt: string;
|
alt: string;
|
||||||
|
|
||||||
/**
|
/** Width of the image in pixels */
|
||||||
* Base width of the image
|
|
||||||
*/
|
|
||||||
width?: number;
|
width?: number;
|
||||||
|
|
||||||
/**
|
/** Height of the image in pixels */
|
||||||
* Base height of the image
|
|
||||||
*/
|
|
||||||
height?: number;
|
height?: number;
|
||||||
|
|
||||||
/**
|
/** Additional CSS classes to apply to the container */
|
||||||
* CSS class to apply to the image
|
|
||||||
*/
|
|
||||||
class?: string;
|
class?: string;
|
||||||
|
|
||||||
/**
|
/** Responsive sizes attribute (e.g. "(max-width: 768px) 100vw, 768px") */
|
||||||
* Sizes attribute for responsive images
|
|
||||||
* @default "(min-width: 1280px) 1280px, (min-width: 1024px) 1024px, (min-width: 768px) 768px, 100vw"
|
|
||||||
*/
|
|
||||||
sizes?: string;
|
sizes?: string;
|
||||||
|
|
||||||
/**
|
/** Loading strategy ('lazy' or 'eager') */
|
||||||
* Loading strategy
|
|
||||||
* @default "lazy"
|
|
||||||
*/
|
|
||||||
loading?: 'eager' | 'lazy';
|
loading?: 'eager' | 'lazy';
|
||||||
|
|
||||||
/**
|
/** Decoding strategy */
|
||||||
* Decoding strategy
|
|
||||||
* @default "async"
|
|
||||||
*/
|
|
||||||
decoding?: 'sync' | 'async' | 'auto';
|
decoding?: 'sync' | 'async' | 'auto';
|
||||||
|
|
||||||
/**
|
/** Image format to convert to */
|
||||||
* Image format
|
|
||||||
* @default "auto"
|
|
||||||
*/
|
|
||||||
format?: 'webp' | 'avif' | 'png' | 'jpeg' | 'jpg' | 'auto';
|
format?: 'webp' | 'avif' | 'png' | 'jpeg' | 'jpg' | 'auto';
|
||||||
|
|
||||||
/**
|
/** Image quality (1-100) */
|
||||||
* Image quality (1-100)
|
|
||||||
* @default 80
|
|
||||||
*/
|
|
||||||
quality?: number;
|
quality?: number;
|
||||||
|
|
||||||
/**
|
/** Whether to apply a blur-up loading effect */
|
||||||
* Whether to add a blur-up effect
|
|
||||||
* @default false
|
|
||||||
*/
|
|
||||||
blurUp?: boolean;
|
blurUp?: boolean;
|
||||||
|
|
||||||
/**
|
/** Whether this is a high-priority image (sets fetchpriority="high" and loading="eager") */
|
||||||
* Whether this is a priority image (above the fold)
|
|
||||||
* @default false
|
|
||||||
*/
|
|
||||||
priority?: boolean;
|
priority?: boolean;
|
||||||
|
|
||||||
/**
|
/** Breakpoints for responsive images */
|
||||||
* Breakpoints for responsive images
|
|
||||||
* @default [640, 768, 1024, 1280]
|
|
||||||
*/
|
|
||||||
breakpoints?: number[];
|
breakpoints?: number[];
|
||||||
|
|
||||||
/**
|
/** Whether to use art direction with different image sources for different screen sizes */
|
||||||
* Whether to use art direction (different images for different breakpoints)
|
|
||||||
* @default false
|
|
||||||
*/
|
|
||||||
artDirected?: boolean;
|
artDirected?: boolean;
|
||||||
|
|
||||||
/**
|
/** Source for mobile screens (max-width: 640px) */
|
||||||
* Mobile image source (for art direction)
|
|
||||||
*/
|
|
||||||
mobileSrc?: ImageMetadata | string;
|
mobileSrc?: ImageMetadata | string;
|
||||||
|
|
||||||
/**
|
/** Source for tablet screens (641px to 1023px) */
|
||||||
* Tablet image source (for art direction)
|
|
||||||
*/
|
|
||||||
tabletSrc?: ImageMetadata | string;
|
tabletSrc?: ImageMetadata | string;
|
||||||
|
|
||||||
/**
|
/** Source for desktop screens (min-width: 1024px) */
|
||||||
* Desktop image source (for art direction)
|
|
||||||
*/
|
|
||||||
desktopSrc?: ImageMetadata | string;
|
desktopSrc?: ImageMetadata | string;
|
||||||
|
|
||||||
|
/** Additional sources for art-directed images with custom media queries */
|
||||||
|
additionalSources?: Array<{
|
||||||
|
media: string;
|
||||||
|
src: ImageMetadata | string;
|
||||||
|
type?: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
/** Whether to add a container div around the image */
|
||||||
|
container?: boolean;
|
||||||
|
|
||||||
|
/** Aspect ratio to maintain (e.g. "16:9", "4:3", "1:1") */
|
||||||
|
aspectRatio?: string;
|
||||||
|
|
||||||
|
/** Whether to use native lazy loading */
|
||||||
|
nativeLazy?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -119,41 +108,141 @@ const {
|
|||||||
artDirected = false,
|
artDirected = false,
|
||||||
mobileSrc,
|
mobileSrc,
|
||||||
tabletSrc,
|
tabletSrc,
|
||||||
desktopSrc
|
desktopSrc,
|
||||||
|
additionalSources = [],
|
||||||
|
container = true,
|
||||||
|
aspectRatio,
|
||||||
|
nativeLazy = true
|
||||||
} = Astro.props;
|
} = Astro.props;
|
||||||
|
|
||||||
// Determine loading strategy based on priority
|
// Determine loading strategy based on priority
|
||||||
const loading = priority ? 'eager' : propLoading || 'lazy';
|
const loading = priority ? 'eager' : propLoading || 'lazy';
|
||||||
|
|
||||||
// Generate a unique ID for this image instance
|
// Set fetchpriority based on priority
|
||||||
const imageId = `img-${Math.random().toString(36).substring(2, 11)}`;
|
const fetchPriority = priority ? 'high' : 'auto';
|
||||||
|
|
||||||
|
// Generate a unique ID using crypto for better randomness
|
||||||
|
const imageId = `img-${crypto.randomUUID().slice(0, 8)}`;
|
||||||
|
|
||||||
// Determine if we're using a string URL or an imported image
|
// Determine if we're using a string URL or an imported image
|
||||||
const isStringSource = typeof src === 'string';
|
const isStringSource = typeof src === 'string';
|
||||||
|
|
||||||
// Placeholder for blur-up effect (simplified version)
|
// Simplified placeholder style
|
||||||
const placeholderStyle = blurUp ? 'filter: blur(20px); transition: filter 0.5s ease-out;' : '';
|
const placeholderClass = blurUp ? 'blur-up' : '';
|
||||||
|
|
||||||
|
// Calculate aspect ratio styles if provided
|
||||||
|
let aspectRatioStyle = '';
|
||||||
|
if (aspectRatio) {
|
||||||
|
const [width, height] = aspectRatio.split(':').map(Number);
|
||||||
|
if (width && height) {
|
||||||
|
const paddingBottom = (height / width) * 100;
|
||||||
|
aspectRatioStyle = `--aspect-ratio: ${paddingBottom}%;`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Container class based on aspect ratio
|
||||||
|
const containerClass = aspectRatio ? 'responsive-container aspect-ratio' : 'responsive-container';
|
||||||
---
|
---
|
||||||
|
|
||||||
{artDirected ? (
|
{artDirected ? (
|
||||||
<picture>
|
<picture class={`responsive-picture ${className}`} style={aspectRatioStyle}>
|
||||||
|
{/* AVIF format sources if not using string sources */}
|
||||||
|
{!isStringSource && format === 'auto' && (
|
||||||
|
<>
|
||||||
|
{/* Mobile AVIF */}
|
||||||
|
{mobileSrc && typeof mobileSrc !== 'string' && (
|
||||||
|
<source
|
||||||
|
type="image/avif"
|
||||||
|
media="(max-width: 640px)"
|
||||||
|
srcset={`${mobileSrc.src}?w=${width || mobileSrc.width}&format=avif&q=${quality}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tablet AVIF */}
|
||||||
|
{tabletSrc && typeof tabletSrc !== 'string' && (
|
||||||
|
<source
|
||||||
|
type="image/avif"
|
||||||
|
media="(min-width: 641px) and (max-width: 1023px)"
|
||||||
|
srcset={`${tabletSrc.src}?w=${width || tabletSrc.width}&format=avif&q=${quality}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Desktop AVIF */}
|
||||||
|
{desktopSrc && typeof desktopSrc !== 'string' && (
|
||||||
|
<source
|
||||||
|
type="image/avif"
|
||||||
|
media="(min-width: 1024px)"
|
||||||
|
srcset={`${desktopSrc.src}?w=${width || desktopSrc.width}&format=avif&q=${quality}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* WebP format sources if not using string sources */}
|
||||||
|
{!isStringSource && format === 'auto' && (
|
||||||
|
<>
|
||||||
|
{/* Mobile WebP */}
|
||||||
|
{mobileSrc && typeof mobileSrc !== 'string' && (
|
||||||
|
<source
|
||||||
|
type="image/webp"
|
||||||
|
media="(max-width: 640px)"
|
||||||
|
srcset={`${mobileSrc.src}?w=${width || mobileSrc.width}&format=webp&q=${quality}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tablet WebP */}
|
||||||
|
{tabletSrc && typeof tabletSrc !== 'string' && (
|
||||||
|
<source
|
||||||
|
type="image/webp"
|
||||||
|
media="(min-width: 641px) and (max-width: 1023px)"
|
||||||
|
srcset={`${tabletSrc.src}?w=${width || tabletSrc.width}&format=webp&q=${quality}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Desktop WebP */}
|
||||||
|
{desktopSrc && typeof desktopSrc !== 'string' && (
|
||||||
|
<source
|
||||||
|
type="image/webp"
|
||||||
|
media="(min-width: 1024px)"
|
||||||
|
srcset={`${desktopSrc.src}?w=${width || desktopSrc.width}&format=webp&q=${quality}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Original format sources */}
|
||||||
{/* Mobile image */}
|
{/* Mobile image */}
|
||||||
<source
|
{mobileSrc && (
|
||||||
media="(max-width: 640px)"
|
<source
|
||||||
srcset={typeof mobileSrc === 'string' ? mobileSrc : typeof src === 'string' ? src : ''}
|
media="(max-width: 640px)"
|
||||||
/>
|
srcset={typeof mobileSrc === 'string' ? mobileSrc : ''}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Tablet image */}
|
{/* Tablet image */}
|
||||||
<source
|
{tabletSrc && (
|
||||||
media="(min-width: 641px) and (max-width: 1023px)"
|
<source
|
||||||
srcset={typeof tabletSrc === 'string' ? tabletSrc : typeof src === 'string' ? src : ''}
|
media="(min-width: 641px) and (max-width: 1023px)"
|
||||||
/>
|
srcset={typeof tabletSrc === 'string' ? tabletSrc : ''}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Desktop image */}
|
{/* Desktop image */}
|
||||||
<source
|
{desktopSrc && (
|
||||||
media="(min-width: 1024px)"
|
<source
|
||||||
srcset={typeof desktopSrc === 'string' ? desktopSrc : typeof src === 'string' ? src : ''}
|
media="(min-width: 1024px)"
|
||||||
/>
|
srcset={typeof desktopSrc === 'string' ? desktopSrc : ''}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Additional custom sources */}
|
||||||
|
{additionalSources.map(source => (
|
||||||
|
<source
|
||||||
|
media={source.media}
|
||||||
|
srcset={typeof source.src === 'string' ? source.src : ''}
|
||||||
|
type={source.type}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
{/* Fallback image */}
|
{/* Fallback image */}
|
||||||
{isStringSource ? (
|
{isStringSource ? (
|
||||||
@ -162,11 +251,12 @@ const placeholderStyle = blurUp ? 'filter: blur(20px); transition: filter 0.5s e
|
|||||||
alt={alt}
|
alt={alt}
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
class={className}
|
class={`responsive-img ${placeholderClass}`}
|
||||||
loading={loading}
|
loading={nativeLazy ? loading : undefined}
|
||||||
decoding={decoding}
|
decoding={decoding}
|
||||||
|
fetchpriority={fetchPriority}
|
||||||
id={imageId}
|
id={imageId}
|
||||||
style={placeholderStyle}
|
data-loading={!nativeLazy && loading === 'lazy' ? 'true' : undefined}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Image
|
<Image
|
||||||
@ -174,70 +264,199 @@ const placeholderStyle = blurUp ? 'filter: blur(20px); transition: filter 0.5s e
|
|||||||
alt={alt}
|
alt={alt}
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
class={className}
|
class={`responsive-img ${placeholderClass}`}
|
||||||
sizes={sizes}
|
sizes={sizes}
|
||||||
loading={loading}
|
loading={nativeLazy ? loading : undefined}
|
||||||
decoding={decoding}
|
decoding={decoding}
|
||||||
quality={quality}
|
quality={quality}
|
||||||
|
fetchpriority={fetchPriority}
|
||||||
id={imageId}
|
id={imageId}
|
||||||
|
data-loading={!nativeLazy && loading === 'lazy' ? 'true' : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</picture>
|
</picture>
|
||||||
) : (
|
) : (
|
||||||
/* Standard responsive image */
|
/* Standard responsive image */
|
||||||
isStringSource ? (
|
container ? (
|
||||||
<img
|
<div class={`${containerClass} ${className}`} style={aspectRatioStyle}>
|
||||||
src={src}
|
{isStringSource ? (
|
||||||
alt={alt}
|
<img
|
||||||
width={width}
|
src={src}
|
||||||
height={height}
|
alt={alt}
|
||||||
class={className}
|
width={width}
|
||||||
loading={loading}
|
height={height}
|
||||||
decoding={decoding}
|
class={`responsive-img ${placeholderClass}`}
|
||||||
id={imageId}
|
loading={nativeLazy ? loading : undefined}
|
||||||
style={placeholderStyle}
|
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>
|
||||||
) : (
|
) : (
|
||||||
<Image
|
/* No container */
|
||||||
src={src}
|
isStringSource ? (
|
||||||
alt={alt}
|
<img
|
||||||
width={width}
|
src={src}
|
||||||
height={height}
|
alt={alt}
|
||||||
class={className}
|
width={width}
|
||||||
sizes={sizes}
|
height={height}
|
||||||
loading={loading}
|
class={`responsive-img ${placeholderClass} ${className}`}
|
||||||
decoding={decoding}
|
loading={nativeLazy ? loading : undefined}
|
||||||
format={format === 'auto' ? undefined : format}
|
decoding={decoding}
|
||||||
quality={quality}
|
fetchpriority={fetchPriority}
|
||||||
id={imageId}
|
id={imageId}
|
||||||
/>
|
data-loading={!nativeLazy && loading === 'lazy' ? 'true' : undefined}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Image
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
class={`responsive-img ${placeholderClass} ${className}`}
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{blurUp && (
|
{(blurUp || !nativeLazy) && (
|
||||||
<script define:vars={{ imageId }}>
|
<script define:vars={{ imageId, blurUp, nativeLazy, loading }}>
|
||||||
// Simple blur-up effect
|
// Enhanced image loading with IntersectionObserver
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const img = document.getElementById(imageId);
|
const img = document.getElementById(imageId);
|
||||||
if (img) {
|
if (!img) return;
|
||||||
img.onload = () => {
|
|
||||||
img.style.filter = 'blur(0)';
|
// Function to handle image load completion
|
||||||
};
|
const handleImageLoaded = () => {
|
||||||
}
|
if (blurUp) {
|
||||||
|
img.classList.add('loaded');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch a custom event that other components can listen for
|
||||||
|
img.dispatchEvent(new CustomEvent('imageLoaded', {
|
||||||
|
bubbles: true,
|
||||||
|
detail: { imageId }
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use IntersectionObserver for both blur-up effect and custom lazy loading
|
||||||
|
const observer = new IntersectionObserver((entries) => {
|
||||||
|
entries.forEach(entry => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
// Set up the onload handler
|
||||||
|
img.onload = handleImageLoaded;
|
||||||
|
|
||||||
|
// If not using native lazy loading and this is a lazy image,
|
||||||
|
// we need to set the src attribute now
|
||||||
|
if (!nativeLazy && loading === 'lazy') {
|
||||||
|
const dataSrc = img.getAttribute('data-src');
|
||||||
|
if (dataSrc) {
|
||||||
|
img.src = dataSrc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If image is already loaded, handle it immediately
|
||||||
|
if (img.complete) {
|
||||||
|
handleImageLoaded();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop observing once we've handled this image
|
||||||
|
observer.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, {
|
||||||
|
// Use a small rootMargin to start loading slightly before the image is visible
|
||||||
|
rootMargin: '200px',
|
||||||
|
threshold: 0.01
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(img);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<style>
|
<style define:vars={{ aspectRatioStyle }}>
|
||||||
/* Prevent layout shifts by maintaining aspect ratio */
|
/* Base container styles */
|
||||||
img {
|
.responsive-container {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Aspect ratio container */
|
||||||
|
.aspect-ratio {
|
||||||
|
position: relative;
|
||||||
|
padding-bottom: var(--aspect-ratio, 56.25%); /* Default to 16:9 if not specified */
|
||||||
|
}
|
||||||
|
|
||||||
|
.aspect-ratio .responsive-img {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Picture element styles */
|
||||||
|
.responsive-picture {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base image styles */
|
||||||
|
.responsive-img {
|
||||||
display: block;
|
display: block;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Improved blur-up effect with smoother transition */
|
||||||
|
.blur-up {
|
||||||
|
filter: blur(20px);
|
||||||
|
transform: scale(1.05);
|
||||||
|
transition:
|
||||||
|
filter 0.5s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
transform 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
will-change: filter, transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blur-up.loaded {
|
||||||
|
filter: blur(0);
|
||||||
|
transform: scale(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Add subtle loading animation for lazy-loaded images */
|
/* Add subtle loading animation for lazy-loaded images */
|
||||||
img:not([loading="eager"]) {
|
img:not([loading="eager"]):not(.blur-up) {
|
||||||
animation: fadeIn 0.5s ease-in-out;
|
animation: fadeIn 0.5s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -245,4 +464,35 @@ const placeholderStyle = blurUp ? 'filter: blur(20px); transition: filter 0.5s e
|
|||||||
from { opacity: 0; }
|
from { opacity: 0; }
|
||||||
to { opacity: 1; }
|
to { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Reduced motion preference */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.blur-up {
|
||||||
|
transition: filter 0.1s ease-out;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
img:not([loading="eager"]):not(.blur-up) {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Container query support for responsive sizing within components */
|
||||||
|
@supports (container-type: inline-size) {
|
||||||
|
.responsive-container {
|
||||||
|
container-type: inline-size;
|
||||||
|
}
|
||||||
|
|
||||||
|
@container (max-width: 400px) {
|
||||||
|
.responsive-img {
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@container (min-width: 401px) {
|
||||||
|
.responsive-img {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -19,13 +19,13 @@ const {
|
|||||||
class: className = '',
|
class: className = '',
|
||||||
} = Astro.props;
|
} = Astro.props;
|
||||||
|
|
||||||
const id = `scroll-reveal-${Math.random().toString(36).substring(2, 9)}`;
|
// Generate a unique ID with better entropy
|
||||||
|
const id = `scroll-reveal-${crypto.randomUUID().slice(0, 8)}`;
|
||||||
---
|
---
|
||||||
|
|
||||||
<div
|
<div
|
||||||
id={id}
|
id={id}
|
||||||
class:list={['scroll-reveal', className]}
|
class:list={['scroll-reveal', `reveal-${animation}`, className]}
|
||||||
data-animation={animation}
|
|
||||||
data-duration={duration}
|
data-duration={duration}
|
||||||
data-delay={delay}
|
data-delay={delay}
|
||||||
data-threshold={threshold}
|
data-threshold={threshold}
|
||||||
@ -39,32 +39,18 @@ const id = `scroll-reveal-${Math.random().toString(36).substring(2, 9)}`;
|
|||||||
.scroll-reveal {
|
.scroll-reveal {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
will-change: transform, opacity;
|
will-change: transform, opacity;
|
||||||
|
transition-property: opacity, transform;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scroll-reveal[data-animation="fade-up"] {
|
/* Initial states based on animation type */
|
||||||
transform: translateY(30px);
|
.reveal-fade-up { transform: translateY(30px); }
|
||||||
}
|
.reveal-fade-down { transform: translateY(-30px); }
|
||||||
|
.reveal-fade-left { transform: translateX(30px); }
|
||||||
.scroll-reveal[data-animation="fade-down"] {
|
.reveal-fade-right { transform: translateX(-30px); }
|
||||||
transform: translateY(-30px);
|
.reveal-zoom-in { transform: scale(0.9); }
|
||||||
}
|
.reveal-zoom-out { transform: scale(1.1); }
|
||||||
|
|
||||||
.scroll-reveal[data-animation="fade-left"] {
|
|
||||||
transform: translateX(30px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scroll-reveal[data-animation="fade-right"] {
|
|
||||||
transform: translateX(-30px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scroll-reveal[data-animation="zoom-in"] {
|
|
||||||
transform: scale(0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scroll-reveal[data-animation="zoom-out"] {
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/* Revealed state */
|
||||||
.scroll-reveal.revealed {
|
.scroll-reveal.revealed {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translate(0) scale(1);
|
transform: translate(0) scale(1);
|
||||||
|
@ -35,26 +35,33 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { name, href, img, alt } = Astro.props;
|
const { name, href, img, alt } = Astro.props;
|
||||||
|
|
||||||
|
// Generate a unique ID for the service card
|
||||||
|
const cardId = `service-card-${crypto.randomUUID().slice(0, 8)}`;
|
||||||
---
|
---
|
||||||
|
|
||||||
<a
|
<a
|
||||||
|
id={cardId}
|
||||||
href={href}
|
href={href}
|
||||||
class="service-card zag-interactive flex items-center transition-all duration-300"
|
class="service-card zag-interactive flex items-center"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
aria-label={`Open ${name} in a new tab`}
|
aria-label={`Open ${name} in a new tab`}
|
||||||
>
|
>
|
||||||
<div class="service-icon-container flex-shrink-0 relative">
|
<div class="service-icon-container flex-shrink-0 relative">
|
||||||
<div class="service-icon-background absolute inset-0 rounded-full opacity-0 transition-all duration-300"></div>
|
<div class="service-icon-background"></div>
|
||||||
<img
|
<img
|
||||||
src={img}
|
src={img}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
class="service-icon w-16 h-16 transition-all duration-300 relative z-10"
|
class="service-icon"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
|
width="64"
|
||||||
|
height="64"
|
||||||
|
fetchpriority="low"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p class="service-name mt-2 text-center transition-all duration-300">{name}</p>
|
<p class="service-name">{name}</p>
|
||||||
|
|
||||||
<!-- QR code for print view only -->
|
<!-- QR code for print view only -->
|
||||||
<div class="print-qr-code">
|
<div class="print-qr-code">
|
||||||
@ -64,18 +71,138 @@ const { name, href, img, alt } = Astro.props;
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* Default (grid) view */
|
/* Base card styles with CSS custom properties */
|
||||||
.service-card {
|
.service-card {
|
||||||
|
--card-transition-duration: 0.3s;
|
||||||
|
--card-transition-timing: cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||||
|
--card-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
--card-hover-shadow: 0 10px 20px rgba(0, 0, 0, 0.15);
|
||||||
|
--card-active-shadow: 0 5px 10px rgba(0, 0, 0, 0.1);
|
||||||
|
--card-border-radius: 0.5rem;
|
||||||
|
--card-padding: 0.5rem;
|
||||||
|
--icon-size: 4rem;
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
|
/* Appearance */
|
||||||
|
box-shadow: var(--card-shadow);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background-color: var(--color-zag-bg);
|
||||||
|
border-radius: var(--card-border-radius);
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
/* Transitions */
|
||||||
|
transition:
|
||||||
|
transform var(--card-transition-duration) var(--card-transition-timing),
|
||||||
|
box-shadow var(--card-transition-duration) var(--card-transition-timing),
|
||||||
|
border-color var(--card-transition-duration) var(--card-transition-timing),
|
||||||
|
background-color var(--card-transition-duration) var(--card-transition-timing);
|
||||||
|
|
||||||
|
/* Performance optimizations */
|
||||||
|
will-change: transform, box-shadow, border-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* List view adjustments applied via JS */
|
/* Gradient background effect */
|
||||||
|
.service-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(135deg, transparent 0%, var(--color-zag-accent) 300%);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--card-transition-duration) var(--card-transition-timing);
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon container */
|
||||||
|
.service-icon-container {
|
||||||
|
position: relative;
|
||||||
|
transition: transform var(--card-transition-duration) var(--card-transition-timing);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon background glow */
|
||||||
|
.service-icon-background {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: radial-gradient(circle, var(--color-zag-accent) 0%, transparent 70%);
|
||||||
|
border-radius: 50%;
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.8);
|
||||||
|
transition:
|
||||||
|
opacity var(--card-transition-duration) var(--card-transition-timing),
|
||||||
|
transform var(--card-transition-duration) var(--card-transition-timing);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon image */
|
||||||
|
.service-icon {
|
||||||
|
width: var(--icon-size);
|
||||||
|
height: var(--icon-size);
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
transition:
|
||||||
|
transform var(--card-transition-duration) var(--card-transition-timing),
|
||||||
|
filter var(--card-transition-duration) var(--card-transition-timing);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Service name */
|
||||||
|
.service-name {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
transition:
|
||||||
|
transform var(--card-transition-duration) var(--card-transition-timing),
|
||||||
|
font-weight var(--card-transition-duration) var(--card-transition-timing);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover effects */
|
||||||
|
.service-card:hover {
|
||||||
|
transform: translateY(-4px) scale(1.02);
|
||||||
|
box-shadow: var(--card-hover-shadow);
|
||||||
|
border-color: var(--color-zag-accent);
|
||||||
|
background-color: var(--color-zag-bg-hover);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-card:hover::before {
|
||||||
|
opacity: 0.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-card:hover .service-icon-container {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-card:hover .service-icon {
|
||||||
|
transform: scale(1.1) rotate(2deg);
|
||||||
|
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.1));
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-card:hover .service-icon-background {
|
||||||
|
opacity: 0.2;
|
||||||
|
transform: scale(1.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-card:hover .service-name {
|
||||||
|
transform: translateY(2px);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active state */
|
||||||
|
.service-card:active {
|
||||||
|
transform: translateY(-2px) scale(0.98);
|
||||||
|
box-shadow: var(--card-active-shadow);
|
||||||
|
transition: all 0.1s var(--card-transition-timing);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* List view styles */
|
||||||
:global(.view-mode-list) .service-card {
|
:global(.view-mode-list) .service-card {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
padding: 0.5rem;
|
padding: var(--card-padding);
|
||||||
border-radius: 0.375rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.view-mode-list) .service-name {
|
:global(.view-mode-list) .service-name {
|
||||||
@ -84,12 +211,15 @@ const { name, href, img, alt } = Astro.props;
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Display mode styles */
|
:global(.view-mode-list) .service-card:hover {
|
||||||
/* Default display mode (both) */
|
transform: translateX(4px) scale(1.01);
|
||||||
.service-icon-container, .service-name {
|
|
||||||
display: block;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global(.view-mode-list) .service-card:active {
|
||||||
|
transform: translateX(2px) scale(0.99);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Display mode styles */
|
||||||
/* Image only mode */
|
/* Image only mode */
|
||||||
:global(.display-image-only) .service-name {
|
:global(.display-image-only) .service-name {
|
||||||
display: none;
|
display: none;
|
||||||
@ -119,125 +249,31 @@ const { name, href, img, alt } = Astro.props;
|
|||||||
|
|
||||||
:global(.view-mode-list.display-image-only) .service-card {
|
:global(.view-mode-list.display-image-only) .service-card {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 0.5rem;
|
padding: var(--card-padding);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Icon size adjustments with CSS variables for fine-grained control */
|
/* Icon size adjustments */
|
||||||
:global(#app-list) {
|
:global(#app-list) {
|
||||||
--icon-scale: 2; /* Default medium size */
|
--icon-scale: 2; /* Default medium size */
|
||||||
--icon-base-size: 1rem;
|
--icon-base-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.service-icon {
|
|
||||||
width: calc(var(--icon-base-size) * var(--icon-scale) * 2);
|
|
||||||
height: calc(var(--icon-base-size) * var(--icon-scale) * 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Fallback discrete sizes for browsers that don't support calc */
|
|
||||||
:global(.icon-size-small) .service-icon {
|
:global(.icon-size-small) .service-icon {
|
||||||
width: 2rem;
|
--icon-size: 2rem;
|
||||||
height: 2rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.icon-size-medium) .service-icon {
|
:global(.icon-size-medium) .service-icon {
|
||||||
width: 4rem;
|
--icon-size: 4rem;
|
||||||
height: 4rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.icon-size-large) .service-icon {
|
:global(.icon-size-large) .service-icon {
|
||||||
width: 6rem;
|
--icon-size: 6rem;
|
||||||
height: 6rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Enhanced hover effects */
|
|
||||||
.service-card {
|
|
||||||
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
||||||
border: 2px solid transparent;
|
|
||||||
background-color: var(--color-zag-bg);
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
will-change: transform, box-shadow, border-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
.service-card::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(135deg, transparent 0%, var(--color-zag-accent) 300%);
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
|
||||||
z-index: -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Enhanced micro-interactions */
|
|
||||||
.service-card:hover {
|
|
||||||
transform: translateY(-4px) scale(1.02);
|
|
||||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.15);
|
|
||||||
border-color: var(--color-zag-accent);
|
|
||||||
background-color: var(--color-zag-bg-hover);
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.service-card:hover::before {
|
|
||||||
opacity: 0.15;
|
|
||||||
}
|
|
||||||
|
|
||||||
.service-card:active {
|
|
||||||
transform: translateY(-2px) scale(0.98);
|
|
||||||
box-shadow: 0 5px 10px rgba(0, 0, 0, 0.1);
|
|
||||||
transition: all 0.1s cubic-bezier(0.25, 0.8, 0.25, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.service-icon-container {
|
|
||||||
position: relative;
|
|
||||||
transition: transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.service-icon-background {
|
|
||||||
background: radial-gradient(circle, var(--color-zag-accent) 0%, transparent 70%);
|
|
||||||
transform: scale(0.8);
|
|
||||||
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.service-card:hover .service-icon-container {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.service-card:hover .service-icon {
|
|
||||||
transform: scale(1.1) rotate(2deg);
|
|
||||||
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.1));
|
|
||||||
}
|
|
||||||
|
|
||||||
.service-card:hover .service-icon-background {
|
|
||||||
opacity: 0.2;
|
|
||||||
transform: scale(1.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.service-card:hover .service-name {
|
|
||||||
transform: translateY(2px);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.view-mode-list) .service-card:hover {
|
|
||||||
transform: translateX(4px) scale(1.01);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.view-mode-list) .service-card:active {
|
|
||||||
transform: translateX(2px) scale(0.99);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode adjustments */
|
/* Dark mode adjustments */
|
||||||
:global(.dark) .service-card {
|
:global(.dark) .service-card {
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
--card-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
}
|
--card-hover-shadow: 0 10px 20px rgba(0, 0, 0, 0.4);
|
||||||
|
|
||||||
:global(.dark) .service-card:hover {
|
|
||||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Print-specific styles */
|
/* Print-specific styles */
|
||||||
@ -304,4 +340,27 @@ const { name, href, img, alt } = Astro.props;
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Reduced motion preference */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.service-card,
|
||||||
|
.service-card::before,
|
||||||
|
.service-icon-container,
|
||||||
|
.service-icon-background,
|
||||||
|
.service-icon,
|
||||||
|
.service-name {
|
||||||
|
transition: none !important;
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-card:hover {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-card:hover .service-icon-container,
|
||||||
|
.service-card:hover .service-icon,
|
||||||
|
.service-card:hover .service-name {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -16,40 +16,18 @@ const {
|
|||||||
{showSizeSelector && (
|
{showSizeSelector && (
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-sm zag-text-muted hidden sm:inline">Size:</span>
|
<span class="text-sm zag-text-muted hidden sm:inline">Size:</span>
|
||||||
<div class="size-selector flex items-center gap-1 border-2 border-solid zag-border-b rounded-lg p-1 zag-bg zag-transition">
|
<div class="size-selector flex flex-col items-center border-2 border-solid zag-border-b rounded-lg p-2 zag-bg zag-transition">
|
||||||
<button
|
<input
|
||||||
@click="setIconSize('small')"
|
type="range"
|
||||||
:class="iconSize === 'small' ? 'active-size' : 'inactive-size'"
|
min="1"
|
||||||
class="p-1.5 transition-all rounded-md focus:outline-none focus:ring-2 focus:ring-current"
|
max="3"
|
||||||
aria-label="Small icons"
|
step="1"
|
||||||
title="Small icons (Alt+1)"
|
x-model="iconSizeValue"
|
||||||
>
|
@input="updateIconSizeFromSlider()"
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
class="size-slider w-full"
|
||||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
aria-label="Icon size slider"
|
||||||
</svg>
|
title="Adjust icon size (Alt+1: Small, Alt+2: Medium, Alt+3: Large)"
|
||||||
</button>
|
/>
|
||||||
<button
|
|
||||||
@click="setIconSize('medium')"
|
|
||||||
:class="iconSize === 'medium' ? 'active-size' : 'inactive-size'"
|
|
||||||
class="p-1.5 transition-all rounded-md focus:outline-none focus:ring-2 focus:ring-current"
|
|
||||||
aria-label="Medium icons"
|
|
||||||
title="Medium icons (Alt+2)"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="setIconSize('large')"
|
|
||||||
:class="iconSize === 'large' ? 'active-size' : 'inactive-size'"
|
|
||||||
class="p-1.5 transition-all rounded-md focus:outline-none focus:ring-2 focus:ring-current"
|
|
||||||
aria-label="Large icons"
|
|
||||||
title="Large icons (Alt+3)"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -80,7 +58,8 @@ const {
|
|||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
||||||
<circle cx="12" cy="12" r="3"></circle>
|
<circle cx="8.5" cy="8.5" r="1.5"></circle>
|
||||||
|
<polyline points="21 15 16 10 5 21"></polyline>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@ -163,4 +142,85 @@ const {
|
|||||||
color: var(--color-zag-light);
|
color: var(--color-zag-light);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Slider styles */
|
||||||
|
.size-slider {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--color-zag-light-muted);
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
:where(.dark, .dark *) & {
|
||||||
|
background: var(--color-zag-dark-muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Slider thumb */
|
||||||
|
.size-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-zag-dark);
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid var(--color-zag-light);
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
:where(.dark, .dark *) & {
|
||||||
|
background: var(--color-zag-light);
|
||||||
|
border: 2px solid var(--color-zag-dark);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.size-slider::-moz-range-thumb {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-zag-dark);
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid var(--color-zag-light);
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
:where(.dark, .dark *) & {
|
||||||
|
background: var(--color-zag-light);
|
||||||
|
border: 2px solid var(--color-zag-dark);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover state */
|
||||||
|
.size-slider:hover::-webkit-slider-thumb {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.size-slider:hover::-moz-range-thumb {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus state */
|
||||||
|
.size-slider:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.size-slider:focus::-webkit-slider-thumb {
|
||||||
|
box-shadow: 0 0 0 3px var(--color-zag-light), 0 0 0 5px var(--color-zag-dark-muted);
|
||||||
|
|
||||||
|
:where(.dark, .dark *) & {
|
||||||
|
box-shadow: 0 0 0 3px var(--color-zag-dark), 0 0 0 5px var(--color-zag-light-muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.size-slider:focus::-moz-range-thumb {
|
||||||
|
box-shadow: 0 0 0 3px var(--color-zag-light), 0 0 0 5px var(--color-zag-dark-muted);
|
||||||
|
|
||||||
|
:where(.dark, .dark *) & {
|
||||||
|
box-shadow: 0 0 0 3px var(--color-zag-dark), 0 0 0 5px var(--color-zag-light-muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -125,6 +125,9 @@ import "../styles/global.css";
|
|||||||
<!-- Theme transition script -->
|
<!-- Theme transition script -->
|
||||||
<script src="/src/scripts/ThemeTransition.js"></script>
|
<script src="/src/scripts/ThemeTransition.js"></script>
|
||||||
|
|
||||||
|
<!-- Analytics -->
|
||||||
|
<script defer src="https://analytics.justin.deal/script.js" data-website-id="0b84ab9f-9fb6-4b19-b5a8-5db8fb66e45b" crossorigin="anonymous"></script>
|
||||||
|
|
||||||
<SearchScript />
|
<SearchScript />
|
||||||
<slot name="head" />
|
<slot name="head" />
|
||||||
</head>
|
</head>
|
||||||
|
@ -4,14 +4,6 @@ import { type SocialMedia } from "./types";
|
|||||||
* Social media profiles
|
* Social media profiles
|
||||||
*/
|
*/
|
||||||
export const socials: SocialMedia[] = [
|
export const socials: SocialMedia[] = [
|
||||||
{
|
|
||||||
name: "GitHub",
|
|
||||||
url: "https://github.com/justindeal",
|
|
||||||
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/github-dark.svg",
|
|
||||||
alt: "GitHub Profile",
|
|
||||||
showInFooter: true,
|
|
||||||
showInAbout: true
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "Gitea",
|
name: "Gitea",
|
||||||
url: "https://code.justin.deal/dealjus",
|
url: "https://code.justin.deal/dealjus",
|
||||||
@ -27,5 +19,13 @@ export const socials: SocialMedia[] = [
|
|||||||
alt: "LinkedIn Profile",
|
alt: "LinkedIn Profile",
|
||||||
showInFooter: true,
|
showInFooter: true,
|
||||||
showInAbout: true
|
showInAbout: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GitHub",
|
||||||
|
url: "https://github.com/justintdeal",
|
||||||
|
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/github-dark.svg",
|
||||||
|
alt: "GitHub Profile",
|
||||||
|
showInFooter: true,
|
||||||
|
showInAbout: true
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
@ -18,6 +18,9 @@
|
|||||||
* @property {string} projectShortDescription - A short description of the projects
|
* @property {string} projectShortDescription - A short description of the projects
|
||||||
* @property {string} projectLongDescription - A longer description of the projects
|
* @property {string} projectLongDescription - A longer description of the projects
|
||||||
* @property {string} profileImage - The profile image filename
|
* @property {string} profileImage - The profile image filename
|
||||||
|
* @property {string} githubProfile - The URL to the GitHub profile
|
||||||
|
* @property {string} linkedinProfile - The URL to the LinkedIn profile
|
||||||
|
* @property {string} giteaProfile - The URL to the Gitea profile
|
||||||
* @property {Object} menu - The menu items
|
* @property {Object} menu - The menu items
|
||||||
*/
|
*/
|
||||||
export const GLOBAL = {
|
export const GLOBAL = {
|
||||||
@ -49,6 +52,11 @@ export const GLOBAL = {
|
|||||||
// Profile image
|
// Profile image
|
||||||
profileImage: "pixel_avatar.png",
|
profileImage: "pixel_avatar.png",
|
||||||
|
|
||||||
|
// Social media profiles
|
||||||
|
githubProfile: "https://github.com/justindeal",
|
||||||
|
linkedinProfile: "https://www.linkedin.com/in/justin-deal/",
|
||||||
|
giteaProfile: "https://code.justin.deal/dealjus",
|
||||||
|
|
||||||
// Menu items
|
// Menu items
|
||||||
menu: {
|
menu: {
|
||||||
home: "/",
|
home: "/",
|
||||||
|
@ -146,7 +146,7 @@ const webpageData = {
|
|||||||
<!-- Search and controls container -->
|
<!-- Search and controls container -->
|
||||||
<div class="mb-4 pt-0">
|
<div class="mb-4 pt-0">
|
||||||
<!-- Style controls in a centered row above search -->
|
<!-- Style controls in a centered row above search -->
|
||||||
<div class="w-full flex justify-center mb-4">
|
<div class="w-full flex justify-center mb-4" x-data="styleControls">
|
||||||
<StyleControls />
|
<StyleControls />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
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,111 +3,165 @@
|
|||||||
|
|
||||||
@variant dark (&:where(.dark, .dark *));
|
@variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
/* Prevent theme flash by hiding content until theme is applied */
|
/* Define CSS layers for better organization */
|
||||||
html:not(.theme-loaded) body {
|
@layer base, components, utilities;
|
||||||
display: none;
|
|
||||||
|
@layer base {
|
||||||
|
/* Prevent theme flash by hiding content until theme is applied */
|
||||||
|
html:not(.theme-loaded) body {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enhanced transitions between themes */
|
||||||
|
html.theme-loaded body {
|
||||||
|
transition: background-color 0.5s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
color 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add transition to all themed elements */
|
||||||
|
.theme-transition-element {
|
||||||
|
transition: background-color 0.5s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
color 0.5s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
border-color 0.5s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
fill 0.5s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
stroke 0.5s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
opacity 0.5s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
box-shadow 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disable transitions when user prefers reduced motion */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
html.theme-loaded body,
|
||||||
|
.theme-transition-element {
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Enhanced transitions between themes */
|
@layer components {
|
||||||
html.theme-loaded body {
|
/* Font loading states */
|
||||||
transition: background-color 0.5s cubic-bezier(0.4, 0, 0.2, 1),
|
html:not(.fonts-loaded) body {
|
||||||
color 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
/* Fallback font metrics that match your custom font */
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.fonts-loaded body {
|
||||||
|
/* Your custom font */
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
/* Add a subtle transition for font changes */
|
||||||
|
transition: font-family 0.1s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide elements with x-cloak until Alpine.js is loaded */
|
||||||
|
[x-cloak] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add keyboard focus styling for keyboard navigation */
|
||||||
|
.keyboard-focus {
|
||||||
|
outline: 2px solid var(--color-zag-accent-dark);
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Add transition to all themed elements */
|
@layer base {
|
||||||
.theme-transition-element {
|
/* Font declarations with optimized loading strategies */
|
||||||
transition: background-color 0.5s cubic-bezier(0.4, 0, 0.2, 1),
|
@font-face {
|
||||||
color 0.5s cubic-bezier(0.4, 0, 0.2, 1),
|
font-family: "Literata Variable";
|
||||||
border-color 0.5s cubic-bezier(0.4, 0, 0.2, 1),
|
font-style: normal;
|
||||||
fill 0.5s cubic-bezier(0.4, 0, 0.2, 1),
|
font-display: swap; /* Use swap for text fonts */
|
||||||
stroke 0.5s cubic-bezier(0.4, 0, 0.2, 1),
|
font-weight: 200 900;
|
||||||
opacity 0.5s cubic-bezier(0.4, 0, 0.2, 1),
|
src: url(https://cdn.jsdelivr.net/fontsource/fonts/literata:vf@latest/latin-opsz-normal.woff2)
|
||||||
box-shadow 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
format("woff2-variations");
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "press-start-2p";
|
||||||
|
font-style: normal;
|
||||||
|
font-display: optional; /* Use optional for decorative fonts */
|
||||||
|
font-weight: 400;
|
||||||
|
src: url(https://cdn.jsdelivr.net/fontsource/fonts/press-start-2p@latest/latin-400-normal.woff2)
|
||||||
|
format("woff2"),
|
||||||
|
url(https://cdn.jsdelivr.net/fontsource/fonts/press-start-2p@latest/latin-400-normal.woff)
|
||||||
|
format("woff");
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Font loading states */
|
@layer base {
|
||||||
html:not(.fonts-loaded) body {
|
@theme {
|
||||||
/* Fallback font metrics that match your custom font */
|
/* Font variables */
|
||||||
font-family: monospace;
|
--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;
|
||||||
|
|
||||||
html.fonts-loaded body {
|
/* Gruvbox color mappings */
|
||||||
/* Your custom font */
|
--color-zag-dark: #282828; /* bg0 */
|
||||||
font-family: var(--font-mono);
|
--color-zag-light: #ebdbb2; /* fg */
|
||||||
/* Add a subtle transition for font changes */
|
--color-zag-dark-muted: #928374; /* grey */
|
||||||
transition: font-family 0.1s ease-out;
|
--color-zag-light-muted: #504945; /* bg4 */
|
||||||
}
|
|
||||||
|
|
||||||
/* Hide elements with x-cloak until Alpine.js is loaded */
|
--color-zag-accent-light: #b8bb26; /* primary */
|
||||||
[x-cloak] {
|
--color-zag-accent-light-muted: #a89984; /* button2 */
|
||||||
display: none !important;
|
--color-zag-accent-dark: #fe8019; /* secondary */
|
||||||
}
|
--color-zag-accent-dark-muted: #fabd2f; /* tertiary */
|
||||||
|
|
||||||
/* Add keyboard focus styling for keyboard navigation */
|
/* Card hover effect variables */
|
||||||
.keyboard-focus {
|
--color-zag-bg: rgba(235, 219, 178, 0.8); /* Light mode card background */
|
||||||
outline: 2px solid var(--color-zag-accent-dark);
|
--color-zag-bg-hover: rgba(235, 219, 178, 1); /* Light mode card hover background */
|
||||||
outline-offset: 2px;
|
--color-zag-accent: rgba(184, 187, 38, 0.5); /* Light mode accent border */
|
||||||
}
|
|
||||||
|
|
||||||
/* Font declarations with optimized loading strategies */
|
/* Additional special colors */
|
||||||
@font-face {
|
--color-zag-button-primary: #b8bb26;
|
||||||
font-family: "Literata Variable";
|
--color-zag-button-secondary: #a89984;
|
||||||
font-style: normal;
|
--color-zag-button-red: #fb4934;
|
||||||
font-display: swap; /* Use swap for text fonts */
|
--color-zag-key: #fb4934;
|
||||||
font-weight: 200 900;
|
--color-zag-operator: #fe8019;
|
||||||
src: url(https://cdn.jsdelivr.net/fontsource/fonts/literata:vf@latest/latin-opsz-normal.woff2)
|
--color-zag-value: #d3869b;
|
||||||
format("woff2-variations");
|
--color-zag-type: #fabd2f;
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
|
--color-zag-function: #b8bb26;
|
||||||
U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191,
|
--color-zag-string: #8ec07c;
|
||||||
U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
--color-zag-special: #83a598;
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
/* Spacing and sizing variables */
|
||||||
font-family: "press-start-2p";
|
--space-xs: 0.25rem;
|
||||||
font-style: normal;
|
--space-sm: 0.5rem;
|
||||||
font-display: optional; /* Use optional for decorative fonts */
|
--space-md: 1rem;
|
||||||
font-weight: 400;
|
--space-lg: 1.5rem;
|
||||||
src: url(https://cdn.jsdelivr.net/fontsource/fonts/press-start-2p@latest/latin-400-normal.woff2)
|
--space-xl: 2rem;
|
||||||
format("woff2"),
|
--space-2xl: 3rem;
|
||||||
url(https://cdn.jsdelivr.net/fontsource/fonts/press-start-2p@latest/latin-400-normal.woff)
|
|
||||||
format("woff");
|
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@theme {
|
/* Animation variables */
|
||||||
--font-mono: "IBM Plex Mono", ui-monospace, monospace;
|
--transition-fast: 150ms;
|
||||||
--font-display: "press-start-2p", ui-monospace, monospace;
|
--transition-medium: 300ms;
|
||||||
--font-serif: "Literata Variable", ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
|
--transition-slow: 500ms;
|
||||||
|
--ease-default: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
/* Gruvbox color mappings */
|
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
--color-zag-dark: #282828; /* bg0 */
|
--ease-in: cubic-bezier(0.4, 0, 1, 1);
|
||||||
--color-zag-light: #ebdbb2; /* fg */
|
--ease-out: cubic-bezier(0, 0, 0.2, 1);
|
||||||
--color-zag-dark-muted: #928374; /* grey */
|
}
|
||||||
--color-zag-light-muted: #504945; /* bg4 */
|
|
||||||
|
|
||||||
--color-zag-accent-light: #b8bb26; /* primary */
|
|
||||||
--color-zag-accent-light-muted: #a89984; /* button2 */
|
|
||||||
--color-zag-accent-dark: #fe8019; /* secondary */
|
|
||||||
--color-zag-accent-dark-muted: #fabd2f; /* tertiary */
|
|
||||||
|
|
||||||
/* Card hover effect variables */
|
|
||||||
--color-zag-bg: rgba(235, 219, 178, 0.8); /* Light mode card background */
|
|
||||||
--color-zag-bg-hover: rgba(235, 219, 178, 1); /* Light mode card hover background */
|
|
||||||
--color-zag-accent: rgba(184, 187, 38, 0.5); /* Light mode accent border */
|
|
||||||
|
|
||||||
/* Additional special colors */
|
|
||||||
--color-zag-button-primary: #b8bb26;
|
|
||||||
--color-zag-button-secondary: #a89984;
|
|
||||||
--color-zag-button-red: #fb4934;
|
|
||||||
--color-zag-key: #fb4934;
|
|
||||||
--color-zag-operator: #fe8019;
|
|
||||||
--color-zag-value: #d3869b;
|
|
||||||
--color-zag-type: #fabd2f;
|
|
||||||
--color-zag-function: #b8bb26;
|
|
||||||
--color-zag-string: #8ec07c;
|
|
||||||
--color-zag-special: #83a598;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
@ -116,6 +170,12 @@ html.fonts-loaded body {
|
|||||||
--zag-offset: 6px;
|
--zag-offset: 6px;
|
||||||
--zag-transition-duration: 0.15s;
|
--zag-transition-duration: 0.15s;
|
||||||
--zag-transition-timing-function: ease-in-out;
|
--zag-transition-timing-function: ease-in-out;
|
||||||
|
|
||||||
|
/* Container query breakpoints */
|
||||||
|
--container-sm: 20rem; /* 320px */
|
||||||
|
--container-md: 30rem; /* 480px */
|
||||||
|
--container-lg: 40rem; /* 640px */
|
||||||
|
--container-xl: 60rem; /* 960px */
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
@ -124,6 +184,61 @@ html.fonts-loaded body {
|
|||||||
--color-zag-accent: rgba(254, 128, 25, 0.5); /* Dark mode accent border */
|
--color-zag-accent: rgba(254, 128, 25, 0.5); /* Dark mode accent border */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Container query context setup */
|
||||||
|
.container-query {
|
||||||
|
container-type: inline-size;
|
||||||
|
container-name: layout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Container query responsive classes */
|
||||||
|
@container layout (min-width: 20rem) {
|
||||||
|
.cq\:text-sm {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@container layout (min-width: 30rem) {
|
||||||
|
.cq\:text-base {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@container layout (min-width: 40rem) {
|
||||||
|
.cq\:text-lg {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@container layout (min-width: 60rem) {
|
||||||
|
.cq\:text-xl {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
/* Icon size classes for service cards */
|
||||||
|
.icon-size-small .app-card .app-icon,
|
||||||
|
.icon-size-small .service-card .service-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
transition: width 0.3s ease, height 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-size-medium .app-card .app-icon,
|
||||||
|
.icon-size-medium .service-card .service-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
transition: width 0.3s ease, height 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-size-large .app-card .app-icon,
|
||||||
|
.icon-size-large .service-card .service-icon {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
transition: width 0.3s ease, height 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
/* Interactive element base transitions */
|
/* Interactive element base transitions */
|
||||||
.zag-interactive {
|
.zag-interactive {
|
||||||
position: relative;
|
position: relative;
|
||||||
@ -193,59 +308,92 @@ html.fonts-loaded body {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zag-transition {
|
/* Enhanced parent-child relationships using :has() */
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
/* Style card containers that have images */
|
||||||
transition:
|
.card-container:has(img) {
|
||||||
background-color var(--zag-transition-duration) var(--zag-transition-timing-function),
|
padding-top: 0;
|
||||||
color var(--zag-transition-duration) var(--zag-transition-timing-function),
|
}
|
||||||
fill var(--zag-transition-duration) var(--zag-transition-timing-function),
|
|
||||||
border-color var(--zag-transition-duration) var(--zag-transition-timing-function),
|
/* Style form labels when their inputs are focused */
|
||||||
transform var(--zag-transition-duration) var(--zag-transition-timing-function),
|
label:has(+ input:focus-visible) {
|
||||||
opacity var(--zag-transition-duration) var(--zag-transition-timing-function),
|
color: var(--color-zag-accent-dark);
|
||||||
box-shadow var(--zag-transition-duration) var(--zag-transition-timing-function);
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Theme transition animations for specific elements */
|
@layer utilities {
|
||||||
@keyframes theme-fade-in {
|
.zag-transition {
|
||||||
from { opacity: 0; }
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
to { opacity: 1; }
|
transition:
|
||||||
}
|
background-color var(--zag-transition-duration) var(--zag-transition-timing-function),
|
||||||
|
color var(--zag-transition-duration) var(--zag-transition-timing-function),
|
||||||
@keyframes theme-slide-up {
|
fill var(--zag-transition-duration) var(--zag-transition-timing-function),
|
||||||
from {
|
border-color var(--zag-transition-duration) var(--zag-transition-timing-function),
|
||||||
opacity: 0.5;
|
transform var(--zag-transition-duration) var(--zag-transition-timing-function),
|
||||||
transform: translateY(10px);
|
opacity var(--zag-transition-duration) var(--zag-transition-timing-function),
|
||||||
}
|
box-shadow var(--zag-transition-duration) var(--zag-transition-timing-function);
|
||||||
to {
|
}
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes theme-scale-in {
|
@layer components {
|
||||||
from {
|
/* Theme transition animations for specific elements */
|
||||||
opacity: 0.8;
|
@keyframes theme-fade-in {
|
||||||
transform: scale(0.95);
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
}
|
}
|
||||||
to {
|
|
||||||
opacity: 1;
|
@keyframes theme-slide-up {
|
||||||
transform: scale(1);
|
from {
|
||||||
|
opacity: 0.5;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes theme-scale-in {
|
||||||
|
from {
|
||||||
|
opacity: 0.8;
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-animate-fade {
|
||||||
|
animation: theme-fade-in 0.5s cubic-bezier(0.4, 0, 0.2, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-animate-slide {
|
||||||
|
animation: theme-slide-up 0.5s cubic-bezier(0.4, 0, 0.2, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-animate-scale {
|
||||||
|
animation: theme-scale-in 0.5s cubic-bezier(0.4, 0, 0.2, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disable animations when user prefers reduced motion */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.theme-animate-fade,
|
||||||
|
.theme-animate-slide,
|
||||||
|
.theme-animate-scale {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-animate-fade {
|
@layer utilities {
|
||||||
animation: theme-fade-in 0.5s cubic-bezier(0.4, 0, 0.2, 1) forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-animate-slide {
|
|
||||||
animation: theme-slide-up 0.5s cubic-bezier(0.4, 0, 0.2, 1) forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-animate-scale {
|
|
||||||
animation: theme-scale-in 0.5s cubic-bezier(0.4, 0, 0.2, 1) forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Base backgrounds and text */
|
/* Base backgrounds and text */
|
||||||
.zag-bg {
|
.zag-bg {
|
||||||
background-color: var(--color-zag-light);
|
background-color: var(--color-zag-light);
|
||||||
|
Loading…
x
Reference in New Issue
Block a user