diff --git a/public/offline.html b/public/offline.html index 794cd92..9129ec9 100644 --- a/public/offline.html +++ b/public/offline.html @@ -3,15 +3,27 @@ + Offline | Justin Deal + +
-
📶
-

You're Offline

-

It looks like you've lost your internet connection. Some pages may still be available if you've visited them before.

- +
+ ⚠️ + You are currently offline +
-
+
📶
+

No Internet Connection

+

It looks like you've lost your internet connection. Some pages may still be available if you've visited them before.

+ + + +

Available Pages

Loading cached pages...

    + +
    diff --git a/public/scripts/search/baseSearch.js b/public/scripts/search/baseSearch.js new file mode 100644 index 0000000..f6e3f41 --- /dev/null +++ b/public/scripts/search/baseSearch.js @@ -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; + } + } + } + }; +} diff --git a/public/scripts/search/contentSearch.js b/public/scripts/search/contentSearch.js new file mode 100644 index 0000000..af60bc7 --- /dev/null +++ b/public/scripts/search/contentSearch.js @@ -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' + }); +} diff --git a/public/scripts/search/index.js b/public/scripts/search/index.js new file mode 100644 index 0000000..fa6b487 --- /dev/null +++ b/public/scripts/search/index.js @@ -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); diff --git a/public/scripts/search/servicesSearch.js b/public/scripts/search/servicesSearch.js new file mode 100644 index 0000000..50f89f1 --- /dev/null +++ b/public/scripts/search/servicesSearch.js @@ -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'); + } + } + }; +} diff --git a/public/service-worker.js b/public/service-worker.js index 133f351..8447086 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,9 +1,27 @@ /** * Service Worker for justin.deal - * Provides caching and offline support + * Provides caching and offline support with advanced strategies */ -const CACHE_NAME = 'justin-deal-v1'; +// Cache versioning for easier updates +const CACHE_VERSION = '2'; +const STATIC_CACHE_NAME = `justin-deal-static-v${CACHE_VERSION}`; +const DYNAMIC_CACHE_NAME = `justin-deal-dynamic-v${CACHE_VERSION}`; +const API_CACHE_NAME = `justin-deal-api-v${CACHE_VERSION}`; +const IMAGE_CACHE_NAME = `justin-deal-images-v${CACHE_VERSION}`; + +// Cache expiration times (in milliseconds) +const CACHE_EXPIRATION = { + API: 5 * 60 * 1000, // 5 minutes + DYNAMIC: 24 * 60 * 60 * 1000, // 1 day + IMAGES: 7 * 24 * 60 * 60 * 1000 // 7 days +}; + +// Cache size limits +const CACHE_SIZE_LIMITS = { + DYNAMIC: 50, // items + IMAGES: 100 // items +}; // Assets to cache immediately on service worker install const PRECACHE_ASSETS = [ @@ -11,6 +29,7 @@ const PRECACHE_ASSETS = [ '/index.html', '/favicon.svg', '/site.webmanifest', + '/offline.html', '/favicons/favicon.png', '/favicons/apple-touch-icon.png', '/favicons/favicon-16x16.png', @@ -36,40 +55,110 @@ const ROUTE_STRATEGIES = [ // HTML pages - network first { pattern: /\.html$|\/$/, - strategy: CACHE_STRATEGIES.NETWORK_FIRST + strategy: CACHE_STRATEGIES.NETWORK_FIRST, + cacheName: STATIC_CACHE_NAME }, // CSS and JS - stale while revalidate { pattern: /\.(css|js)$/, - strategy: CACHE_STRATEGIES.STALE_WHILE_REVALIDATE + strategy: CACHE_STRATEGIES.STALE_WHILE_REVALIDATE, + cacheName: STATIC_CACHE_NAME }, // Images - cache first { pattern: /\.(jpe?g|png|gif|svg|webp|avif)$/, - strategy: CACHE_STRATEGIES.CACHE_FIRST + strategy: CACHE_STRATEGIES.CACHE_FIRST, + cacheName: IMAGE_CACHE_NAME }, // Fonts - cache first { pattern: /\.(woff2?|ttf|otf|eot)$/, - strategy: CACHE_STRATEGIES.CACHE_FIRST + strategy: CACHE_STRATEGIES.CACHE_FIRST, + cacheName: STATIC_CACHE_NAME }, - // API requests - network first + // API requests - stale while revalidate { pattern: /\/api\//, - strategy: CACHE_STRATEGIES.NETWORK_FIRST + strategy: CACHE_STRATEGIES.STALE_WHILE_REVALIDATE, + cacheName: API_CACHE_NAME } ]; -// Determine cache strategy for a given URL +// Determine cache strategy and cache name for a given URL function getStrategyForUrl(url) { const matchedRoute = ROUTE_STRATEGIES.find(route => route.pattern.test(url)); - return matchedRoute ? matchedRoute.strategy : CACHE_STRATEGIES.NETWORK_FIRST; + return { + strategy: matchedRoute ? matchedRoute.strategy : CACHE_STRATEGIES.NETWORK_FIRST, + cacheName: matchedRoute ? matchedRoute.cacheName : DYNAMIC_CACHE_NAME + }; +} + +// Helper function to store cache metadata +function storeCacheMetadata(cacheName, url, metadata) { + return caches.open(`${cacheName}-metadata`) + .then(metaCache => { + return metaCache.put( + new Request(`metadata:${url}`), + new Response(JSON.stringify(metadata)) + ); + }); +} + +// Helper function to get cache metadata +async function getCacheMetadata(cacheName, url) { + const metaCache = await caches.open(`${cacheName}-metadata`); + const metadataResponse = await metaCache.match(new Request(`metadata:${url}`)); + + if (metadataResponse) { + return JSON.parse(await metadataResponse.text()); + } + + return null; +} + +// Helper function to check if a cached response is expired +async function isCacheExpired(cacheName, url) { + const metadata = await getCacheMetadata(cacheName, url); + + if (!metadata || !metadata.timestamp) { + return true; + } + + const age = Date.now() - metadata.timestamp; + + switch (cacheName) { + case API_CACHE_NAME: + return age > CACHE_EXPIRATION.API; + case DYNAMIC_CACHE_NAME: + return age > CACHE_EXPIRATION.DYNAMIC; + case IMAGE_CACHE_NAME: + return age > CACHE_EXPIRATION.IMAGES; + default: + return false; // Static cache doesn't expire + } +} + +// Helper function to limit cache size +async function trimCache(cacheName, maxItems) { + const cache = await caches.open(cacheName); + const keys = await cache.keys(); + + if (keys.length > maxItems) { + // Delete oldest items (first in the list) + for (let i = 0; i < keys.length - maxItems; i++) { + await cache.delete(keys[i]); + + // Also delete metadata + const metaCache = await caches.open(`${cacheName}-metadata`); + await metaCache.delete(new Request(`metadata:${keys[i].url}`)); + } + } } // Install event - precache critical assets self.addEventListener('install', event => { event.waitUntil( - caches.open(CACHE_NAME) + caches.open(STATIC_CACHE_NAME) .then(cache => cache.addAll(PRECACHE_ASSETS)) .then(() => self.skipWaiting()) ); @@ -81,7 +170,11 @@ self.addEventListener('activate', event => { caches.keys().then(cacheNames => { return Promise.all( cacheNames - .filter(cacheName => cacheName !== CACHE_NAME) + .filter(cacheName => { + // Keep current version caches + return !cacheName.includes(`-v${CACHE_VERSION}`) && + !cacheName.includes(`-metadata`); + }) .map(cacheName => caches.delete(cacheName)) ); }).then(() => self.clients.claim()) @@ -89,15 +182,21 @@ self.addEventListener('activate', event => { }); // Helper function to handle network-first strategy -async function networkFirstStrategy(request) { +async function networkFirstStrategy(request, cacheName) { try { // Try network first const networkResponse = await fetch(request); // If successful, clone and cache the response if (networkResponse.ok) { - const cache = await caches.open(CACHE_NAME); + const cache = await caches.open(cacheName); cache.put(request, networkResponse.clone()); + + // Store metadata with timestamp + storeCacheMetadata(cacheName, request.url, { + timestamp: Date.now(), + url: request.url + }); } return networkResponse; @@ -114,45 +213,104 @@ async function networkFirstStrategy(request) { } // Helper function to handle cache-first strategy -async function cacheFirstStrategy(request) { +async function cacheFirstStrategy(request, cacheName) { // Try cache first const cachedResponse = await caches.match(request); - if (cachedResponse) { + + // Check if we have a cached response and if it's expired + const isExpired = cachedResponse ? await isCacheExpired(cacheName, request.url) : true; + + // If we have a valid cached response, use it + if (cachedResponse && !isExpired) { return cachedResponse; } - // If not in cache, get from network - const networkResponse = await fetch(request); - - // Cache the response for future - if (networkResponse.ok) { - const cache = await caches.open(CACHE_NAME); - cache.put(request, networkResponse.clone()); + // If not in cache or expired, get from network + try { + const networkResponse = await fetch(request); + + // Cache the response for future + if (networkResponse.ok) { + const cache = await caches.open(cacheName); + cache.put(request, networkResponse.clone()); + + // Store metadata with timestamp + storeCacheMetadata(cacheName, request.url, { + timestamp: Date.now(), + url: request.url + }); + + // Trim cache if needed + if (cacheName === IMAGE_CACHE_NAME) { + trimCache(IMAGE_CACHE_NAME, CACHE_SIZE_LIMITS.IMAGES); + } + } + + return networkResponse; + } catch (error) { + // If network fails and we have an expired cached response, use it as fallback + if (cachedResponse) { + console.log('Using expired cached response as fallback'); + return cachedResponse; + } + + // If no cache, throw error + throw error; } - - return networkResponse; } -// Helper function to handle stale-while-revalidate strategy -async function staleWhileRevalidateStrategy(request) { +// Enhanced stale-while-revalidate strategy with cache expiration and metadata +async function staleWhileRevalidateStrategy(request, cacheName) { + const url = request.url; + const cache = await caches.open(cacheName); + // Try to get from cache - const cachedResponse = await caches.match(request); + const cachedResponse = await cache.match(request); + + // Check if we have a cached response and if it's expired + const isExpired = cachedResponse ? await isCacheExpired(cacheName, url) : true; + + // If we have a cached response, use it immediately (even if expired) + // This is the "stale" part // Fetch from network to update cache (don't await) const fetchPromise = fetch(request) .then(networkResponse => { if (networkResponse.ok) { - const cache = caches.open(CACHE_NAME); - cache.then(cache => cache.put(request, networkResponse.clone())); + // Clone the response before using it + const responseToCache = networkResponse.clone(); + + // Store in cache + cache.put(request, responseToCache); + + // Store metadata with timestamp + storeCacheMetadata(cacheName, url, { + timestamp: Date.now(), + url: url + }); + + // Trim cache if needed + if (cacheName === DYNAMIC_CACHE_NAME) { + trimCache(DYNAMIC_CACHE_NAME, CACHE_SIZE_LIMITS.DYNAMIC); + } else if (cacheName === IMAGE_CACHE_NAME) { + trimCache(IMAGE_CACHE_NAME, CACHE_SIZE_LIMITS.IMAGES); + } } return networkResponse; }) .catch(error => { console.error('Failed to fetch and update cache:', error); + // If we have a cached response but it's expired, still return it + if (cachedResponse) { + console.log('Returning expired cached response as fallback'); + return cachedResponse; + } + throw error; }); - // Return cached response immediately if available - return cachedResponse || fetchPromise; + // Return cached response immediately if available and not expired + // Otherwise wait for the network response + return cachedResponse && !isExpired ? cachedResponse : fetchPromise; } // Fetch event - handle all fetch requests @@ -164,21 +322,21 @@ self.addEventListener('fetch', event => { return; } - // Get the appropriate strategy for this URL - const strategy = getStrategyForUrl(event.request.url); + // Get the appropriate strategy and cache name for this URL + const { strategy, cacheName } = getStrategyForUrl(event.request.url); // Apply the selected strategy switch (strategy) { case CACHE_STRATEGIES.NETWORK_FIRST: - event.respondWith(networkFirstStrategy(event.request)); + event.respondWith(networkFirstStrategy(event.request, cacheName)); break; case CACHE_STRATEGIES.CACHE_FIRST: - event.respondWith(cacheFirstStrategy(event.request)); + event.respondWith(cacheFirstStrategy(event.request, cacheName)); break; case CACHE_STRATEGIES.STALE_WHILE_REVALIDATE: - event.respondWith(staleWhileRevalidateStrategy(event.request)); + event.respondWith(staleWhileRevalidateStrategy(event.request, cacheName)); break; case CACHE_STRATEGIES.CACHE_ONLY: @@ -191,7 +349,7 @@ self.addEventListener('fetch', event => { default: // Default to network first - event.respondWith(networkFirstStrategy(event.request)); + event.respondWith(networkFirstStrategy(event.request, DYNAMIC_CACHE_NAME)); } }); @@ -222,4 +380,21 @@ self.addEventListener('message', event => { if (event.data && event.data.type === 'SKIP_WAITING') { self.skipWaiting(); } + + // Handle cache cleanup request + if (event.data && event.data.type === 'CLEAR_CACHES') { + event.waitUntil( + caches.keys().then(cacheNames => { + return Promise.all( + cacheNames.map(cacheName => caches.delete(cacheName)) + ); + }).then(() => { + // Notify client that caches were cleared + event.ports[0].postMessage({ + status: 'success', + message: 'All caches cleared successfully' + }); + }) + ); + } }); diff --git a/src/components/SearchScript.astro b/src/components/SearchScript.astro index 260f560..d70ac21 100644 --- a/src/components/SearchScript.astro +++ b/src/components/SearchScript.astro @@ -1,418 +1,518 @@ --- ---- - - diff --git a/src/components/StyleControlsScript.astro b/src/components/StyleControlsScript.astro index 0f8769d..c8452eb 100644 --- a/src/components/StyleControlsScript.astro +++ b/src/components/StyleControlsScript.astro @@ -1,66 +1,56 @@ - diff --git a/src/components/common/AnimatedElement.astro b/src/components/common/AnimatedElement.astro index 13c55ef..30437c8 100644 --- a/src/components/common/AnimatedElement.astro +++ b/src/components/common/AnimatedElement.astro @@ -17,58 +17,47 @@ const { tag: Tag = 'div' } = Astro.props; -const animationClasses = { - 'fade': 'animate-fade', - 'scale': 'animate-scale', - 'slide-up': 'animate-slide-up', - 'slide-down': 'animate-slide-down', - 'slide-left': 'animate-slide-left', - 'slide-right': 'animate-slide-right', - 'pulse': 'animate-pulse' -}; - -const animationClass = animationClasses[animation] || ''; +// Validate animation type +const validAnimations = ['fade', 'scale', 'slide-up', 'slide-down', 'slide-left', 'slide-right', 'pulse']; +const animationClass = validAnimations.includes(animation) ? `animate-${animation}` : ''; --- - - +
    + +
    diff --git a/src/components/common/LoadingOverlay.astro b/src/components/common/LoadingOverlay.astro index 7f2b1a0..4a0f613 100644 --- a/src/components/common/LoadingOverlay.astro +++ b/src/components/common/LoadingOverlay.astro @@ -18,6 +18,8 @@ const { class={`loading-overlay fixed inset-0 flex flex-col items-center justify-center z-50 bg-opacity-90 zag-bg ${className}`} role="alert" aria-live="polite" + aria-atomic="true" + data-loading-overlay >
    @@ -73,15 +75,87 @@ const { diff --git a/src/components/common/ResponsiveImage.astro b/src/components/common/ResponsiveImage.astro index 56f7aac..ab7ce04 100644 --- a/src/components/common/ResponsiveImage.astro +++ b/src/components/common/ResponsiveImage.astro @@ -3,103 +3,92 @@ import { Image } from 'astro:assets'; import type { ImageMetadata } from 'astro'; /** - * ResponsiveImage component with advanced features for optimal performance + * ResponsiveImage component provides enhanced image handling with: + * - Art direction support via element + * - Modern format support (AVIF, WebP) + * - Blur-up loading effect + * - Lazy loading with IntersectionObserver + * - Priority hints for important images + * * @component + * @example + * ```astro + * + * ``` */ interface Props { - /** - * The image source (either an imported image or a URL) - */ + /** The image source (either imported via astro:assets or a URL string) */ src: ImageMetadata | string; - /** - * Alternative text for the image - */ + /** Alternative text for the image (required for accessibility) */ alt: string; - /** - * Base width of the image - */ + /** Width of the image in pixels */ width?: number; - /** - * Base height of the image - */ + /** Height of the image in pixels */ height?: number; - /** - * CSS class to apply to the image - */ + /** Additional CSS classes to apply to the container */ class?: string; - /** - * Sizes attribute for responsive images - * @default "(min-width: 1280px) 1280px, (min-width: 1024px) 1024px, (min-width: 768px) 768px, 100vw" - */ + /** Responsive sizes attribute (e.g. "(max-width: 768px) 100vw, 768px") */ sizes?: string; - /** - * Loading strategy - * @default "lazy" - */ + /** Loading strategy ('lazy' or 'eager') */ loading?: 'eager' | 'lazy'; - /** - * Decoding strategy - * @default "async" - */ + /** Decoding strategy */ decoding?: 'sync' | 'async' | 'auto'; - /** - * Image format - * @default "auto" - */ + /** Image format to convert to */ format?: 'webp' | 'avif' | 'png' | 'jpeg' | 'jpg' | 'auto'; - /** - * Image quality (1-100) - * @default 80 - */ + /** Image quality (1-100) */ quality?: number; - /** - * Whether to add a blur-up effect - * @default false - */ + /** Whether to apply a blur-up loading effect */ blurUp?: boolean; - /** - * Whether this is a priority image (above the fold) - * @default false - */ + /** Whether this is a high-priority image (sets fetchpriority="high" and loading="eager") */ priority?: boolean; - /** - * Breakpoints for responsive images - * @default [640, 768, 1024, 1280] - */ + /** Breakpoints for responsive images */ breakpoints?: number[]; - /** - * Whether to use art direction (different images for different breakpoints) - * @default false - */ + /** Whether to use art direction with different image sources for different screen sizes */ artDirected?: boolean; - /** - * Mobile image source (for art direction) - */ + /** Source for mobile screens (max-width: 640px) */ mobileSrc?: ImageMetadata | string; - /** - * Tablet image source (for art direction) - */ + /** Source for tablet screens (641px to 1023px) */ tabletSrc?: ImageMetadata | string; - /** - * Desktop image source (for art direction) - */ + /** Source for desktop screens (min-width: 1024px) */ desktopSrc?: ImageMetadata | string; + + /** Additional sources for art-directed images with custom media queries */ + additionalSources?: Array<{ + media: string; + src: ImageMetadata | string; + type?: string; + }>; + + /** Whether to add a container div around the image */ + container?: boolean; + + /** Aspect ratio to maintain (e.g. "16:9", "4:3", "1:1") */ + aspectRatio?: string; + + /** Whether to use native lazy loading */ + nativeLazy?: boolean; } const { @@ -119,41 +108,141 @@ const { artDirected = false, mobileSrc, tabletSrc, - desktopSrc + desktopSrc, + additionalSources = [], + container = true, + aspectRatio, + nativeLazy = true } = Astro.props; // Determine loading strategy based on priority const loading = priority ? 'eager' : propLoading || 'lazy'; -// Generate a unique ID for this image instance -const imageId = `img-${Math.random().toString(36).substring(2, 11)}`; +// Set fetchpriority based on priority +const fetchPriority = priority ? 'high' : 'auto'; + +// Generate a unique ID using crypto for better randomness +const imageId = `img-${crypto.randomUUID().slice(0, 8)}`; // Determine if we're using a string URL or an imported image const isStringSource = typeof src === 'string'; -// Placeholder for blur-up effect (simplified version) -const placeholderStyle = blurUp ? 'filter: blur(20px); transition: filter 0.5s ease-out;' : ''; +// Simplified placeholder style +const placeholderClass = blurUp ? 'blur-up' : ''; + +// Calculate aspect ratio styles if provided +let aspectRatioStyle = ''; +if (aspectRatio) { + const [width, height] = aspectRatio.split(':').map(Number); + if (width && height) { + const paddingBottom = (height / width) * 100; + aspectRatioStyle = `--aspect-ratio: ${paddingBottom}%;`; + } +} + +// Container class based on aspect ratio +const containerClass = aspectRatio ? 'responsive-container aspect-ratio' : 'responsive-container'; --- {artDirected ? ( - + + {/* AVIF format sources if not using string sources */} + {!isStringSource && format === 'auto' && ( + <> + {/* Mobile AVIF */} + {mobileSrc && typeof mobileSrc !== 'string' && ( + + )} + + {/* Tablet AVIF */} + {tabletSrc && typeof tabletSrc !== 'string' && ( + + )} + + {/* Desktop AVIF */} + {desktopSrc && typeof desktopSrc !== 'string' && ( + + )} + + )} + + {/* WebP format sources if not using string sources */} + {!isStringSource && format === 'auto' && ( + <> + {/* Mobile WebP */} + {mobileSrc && typeof mobileSrc !== 'string' && ( + + )} + + {/* Tablet WebP */} + {tabletSrc && typeof tabletSrc !== 'string' && ( + + )} + + {/* Desktop WebP */} + {desktopSrc && typeof desktopSrc !== 'string' && ( + + )} + + )} + + {/* Original format sources */} {/* Mobile image */} - + {mobileSrc && ( + + )} {/* Tablet image */} - + {tabletSrc && ( + + )} {/* Desktop image */} - + {desktopSrc && ( + + )} + + {/* Additional custom sources */} + {additionalSources.map(source => ( + + ))} {/* Fallback image */} {isStringSource ? ( @@ -162,11 +251,12 @@ const placeholderStyle = blurUp ? 'filter: blur(20px); transition: filter 0.5s e alt={alt} width={width} height={height} - class={className} - loading={loading} + class={`responsive-img ${placeholderClass}`} + loading={nativeLazy ? loading : undefined} decoding={decoding} + fetchpriority={fetchPriority} id={imageId} - style={placeholderStyle} + data-loading={!nativeLazy && loading === 'lazy' ? 'true' : undefined} /> ) : ( {alt} )} ) : ( /* Standard responsive image */ - isStringSource ? ( - {alt} + container ? ( +
    + {isStringSource ? ( + {alt} + ) : ( + {alt} + )} +
    ) : ( - {alt} + /* No container */ + isStringSource ? ( + {alt} + ) : ( + {alt} + ) ) )} -{blurUp && ( - )} - diff --git a/src/components/common/ScrollReveal.astro b/src/components/common/ScrollReveal.astro index d392df2..96e9b0a 100644 --- a/src/components/common/ScrollReveal.astro +++ b/src/components/common/ScrollReveal.astro @@ -19,13 +19,13 @@ const { class: className = '', } = Astro.props; -const id = `scroll-reveal-${Math.random().toString(36).substring(2, 9)}`; +// Generate a unique ID with better entropy +const id = `scroll-reveal-${crypto.randomUUID().slice(0, 8)}`; ---
    -
    +
    {alt}
    -

    {name}

    +

    {name}