diff --git a/.astro/data-store.json b/.astro/data-store.json index edf5924..7f2956d 100644 --- a/.astro/data-store.json +++ b/.astro/data-store.json @@ -1 +1 @@ -[["Map",1,2],"meta::meta",["Map",3,4,5,6],"astro-version","5.7.5","astro-config-digest","{\"root\":{},\"srcDir\":{},\"publicDir\":{},\"outDir\":{},\"cacheDir\":{},\"site\":\"https://justin.deal\",\"compressHTML\":true,\"base\":\"/\",\"trailingSlash\":\"ignore\",\"output\":\"static\",\"scopedStyleStrategy\":\"attribute\",\"build\":{\"format\":\"directory\",\"client\":{},\"server\":{},\"assets\":\"_astro\",\"serverEntry\":\"entry.mjs\",\"redirects\":true,\"inlineStylesheets\":\"auto\",\"concurrency\":1},\"server\":{\"open\":false,\"host\":false,\"port\":4321,\"streaming\":true,\"allowedHosts\":[]},\"redirects\":{},\"image\":{\"endpoint\":{\"route\":\"/_image\"},\"service\":{\"entrypoint\":\"astro/assets/services/sharp\",\"config\":{}},\"domains\":[],\"remotePatterns\":[]},\"devToolbar\":{\"enabled\":true},\"markdown\":{\"syntaxHighlight\":{\"type\":\"shiki\",\"excludeLangs\":[\"math\"]},\"shikiConfig\":{\"langs\":[],\"langAlias\":{},\"theme\":\"github-dark\",\"themes\":{},\"wrap\":false,\"transformers\":[]},\"remarkPlugins\":[],\"rehypePlugins\":[],\"remarkRehype\":{},\"gfm\":true,\"smartypants\":true},\"security\":{\"checkOrigin\":true},\"env\":{\"schema\":{},\"validateSecrets\":false},\"experimental\":{\"clientPrerender\":false,\"contentIntellisense\":false,\"responsiveImages\":false,\"headingIdCompat\":false,\"preserveScriptOrder\":false},\"legacy\":{\"collections\":false}}"] \ No newline at end of file +[["Map",1,2],"meta::meta",["Map",3,4,5,6],"astro-version","5.7.5","astro-config-digest","{\"root\":{},\"srcDir\":{},\"publicDir\":{},\"outDir\":{},\"cacheDir\":{},\"site\":\"https://justin.deal\",\"compressHTML\":true,\"base\":\"/\",\"trailingSlash\":\"ignore\",\"output\":\"static\",\"scopedStyleStrategy\":\"attribute\",\"build\":{\"format\":\"directory\",\"client\":{},\"server\":{},\"assets\":\"_astro\",\"serverEntry\":\"entry.mjs\",\"redirects\":true,\"inlineStylesheets\":\"auto\",\"concurrency\":1},\"server\":{\"open\":false,\"host\":false,\"port\":4321,\"streaming\":true,\"allowedHosts\":[]},\"redirects\":{},\"image\":{\"endpoint\":{\"route\":\"/_image\"},\"service\":{\"entrypoint\":\"astro/assets/services/sharp\",\"config\":{\"quality\":80,\"formats\":[\"webp\",\"avif\",\"png\",\"jpg\"]}},\"domains\":[],\"remotePatterns\":[]},\"devToolbar\":{\"enabled\":true},\"markdown\":{\"syntaxHighlight\":{\"type\":\"shiki\",\"excludeLangs\":[\"math\"]},\"shikiConfig\":{\"langs\":[],\"langAlias\":{},\"theme\":\"github-dark\",\"themes\":{},\"wrap\":false,\"transformers\":[]},\"remarkPlugins\":[],\"rehypePlugins\":[],\"remarkRehype\":{},\"gfm\":true,\"smartypants\":true},\"security\":{\"checkOrigin\":true},\"env\":{\"schema\":{},\"validateSecrets\":false},\"experimental\":{\"clientPrerender\":false,\"contentIntellisense\":false,\"responsiveImages\":false,\"headingIdCompat\":false,\"preserveScriptOrder\":false},\"legacy\":{\"collections\":false}}"] \ No newline at end of file diff --git a/astro.config.mjs b/astro.config.mjs index 121b974..2bd2f41 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -5,7 +5,60 @@ import tailwindcss from "@tailwindcss/vite"; // https://astro.build/config export default defineConfig({ site: 'https://justin.deal', + + // Performance optimizations + compressHTML: true, + + // Build optimizations + build: { + inlineStylesheets: 'auto', // Inline small stylesheets for better performance + }, + + // Image optimizations + image: { + service: { + entrypoint: 'astro/assets/services/sharp', + config: { + quality: 80, // Default image quality + formats: ['webp', 'avif', 'png', 'jpg'], // Supported formats in order of preference + }, + }, + }, + + // Vite configuration vite: { plugins: [tailwindcss()], + + // Build optimizations + build: { + // Enable chunk splitting + cssCodeSplit: true, + + // Optimize chunks + rollupOptions: { + output: { + // Customize chunk naming + manualChunks: { + // Group Alpine.js and related code + alpine: ['alpinejs'], + }, + }, + }, + }, + + // Optimize dependencies + optimizeDeps: { + include: ['alpinejs'], + }, + + // CSS optimization + css: { + devSourcemap: true, + }, + + // Enable server-side rendering optimizations + ssr: { + noExternal: ['@astrojs/tailwind'], + }, }, }); diff --git a/node_modules/.vite/deps/_metadata.json b/node_modules/.vite/deps/_metadata.json index ee29d2a..54409c8 100644 --- a/node_modules/.vite/deps/_metadata.json +++ b/node_modules/.vite/deps/_metadata.json @@ -1,25 +1,25 @@ { - "hash": "492ba4b0", - "configHash": "c5e12679", + "hash": "22066863", + "configHash": "21423e47", "lockfileHash": "53cd0e09", - "browserHash": "c02b8ebb", + "browserHash": "ad44e8cf", "optimized": { "astro > cssesc": { "src": "../../.pnpm/cssesc@3.0.0/node_modules/cssesc/cssesc.js", "file": "astro___cssesc.js", - "fileHash": "22e06d75", + "fileHash": "35f908c6", "needsInterop": true }, "astro > aria-query": { "src": "../../.pnpm/aria-query@5.3.2/node_modules/aria-query/lib/index.js", "file": "astro___aria-query.js", - "fileHash": "c870f0ee", + "fileHash": "13f7590c", "needsInterop": true }, "astro > axobject-query": { "src": "../../.pnpm/axobject-query@4.1.0/node_modules/axobject-query/lib/index.js", "file": "astro___axobject-query.js", - "fileHash": "b44b4671", + "fileHash": "59c979c2", "needsInterop": true } }, diff --git a/public/offline.html b/public/offline.html new file mode 100644 index 0000000..794cd92 --- /dev/null +++ b/public/offline.html @@ -0,0 +1,252 @@ + + + + + + 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.

+ + +
+

Available Pages

+

Loading cached pages...

+ +
+
+ + + + diff --git a/public/service-worker.js b/public/service-worker.js new file mode 100644 index 0000000..133f351 --- /dev/null +++ b/public/service-worker.js @@ -0,0 +1,225 @@ +/** + * Service Worker for justin.deal + * Provides caching and offline support + */ + +const CACHE_NAME = 'justin-deal-v1'; + +// Assets to cache immediately on service worker install +const PRECACHE_ASSETS = [ + '/', + '/index.html', + '/favicon.svg', + '/site.webmanifest', + '/favicons/favicon.png', + '/favicons/apple-touch-icon.png', + '/favicons/favicon-16x16.png', + '/favicons/favicon-32x32.png' +]; + +// Cache strategies +const CACHE_STRATEGIES = { + // Cache first, falling back to network + CACHE_FIRST: 'cache-first', + // Network first, falling back to cache + NETWORK_FIRST: 'network-first', + // Cache only (no network request) + CACHE_ONLY: 'cache-only', + // Network only (no cache lookup) + NETWORK_ONLY: 'network-only', + // Stale while revalidate (serve from cache, update cache in background) + STALE_WHILE_REVALIDATE: 'stale-while-revalidate' +}; + +// Route patterns and their corresponding cache strategies +const ROUTE_STRATEGIES = [ + // HTML pages - network first + { + pattern: /\.html$|\/$/, + strategy: CACHE_STRATEGIES.NETWORK_FIRST + }, + // CSS and JS - stale while revalidate + { + pattern: /\.(css|js)$/, + strategy: CACHE_STRATEGIES.STALE_WHILE_REVALIDATE + }, + // Images - cache first + { + pattern: /\.(jpe?g|png|gif|svg|webp|avif)$/, + strategy: CACHE_STRATEGIES.CACHE_FIRST + }, + // Fonts - cache first + { + pattern: /\.(woff2?|ttf|otf|eot)$/, + strategy: CACHE_STRATEGIES.CACHE_FIRST + }, + // API requests - network first + { + pattern: /\/api\//, + strategy: CACHE_STRATEGIES.NETWORK_FIRST + } +]; + +// Determine cache strategy 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; +} + +// Install event - precache critical assets +self.addEventListener('install', event => { + event.waitUntil( + caches.open(CACHE_NAME) + .then(cache => cache.addAll(PRECACHE_ASSETS)) + .then(() => self.skipWaiting()) + ); +}); + +// Activate event - clean up old caches +self.addEventListener('activate', event => { + event.waitUntil( + caches.keys().then(cacheNames => { + return Promise.all( + cacheNames + .filter(cacheName => cacheName !== CACHE_NAME) + .map(cacheName => caches.delete(cacheName)) + ); + }).then(() => self.clients.claim()) + ); +}); + +// Helper function to handle network-first strategy +async function networkFirstStrategy(request) { + 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); + cache.put(request, networkResponse.clone()); + } + + return networkResponse; + } catch (error) { + // If network fails, try cache + const cachedResponse = await caches.match(request); + if (cachedResponse) { + return cachedResponse; + } + + // If no cache, throw error + throw error; + } +} + +// Helper function to handle cache-first strategy +async function cacheFirstStrategy(request) { + // Try cache first + const cachedResponse = await caches.match(request); + if (cachedResponse) { + 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()); + } + + return networkResponse; +} + +// Helper function to handle stale-while-revalidate strategy +async function staleWhileRevalidateStrategy(request) { + // Try to get from cache + const cachedResponse = await caches.match(request); + + // 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())); + } + return networkResponse; + }) + .catch(error => { + console.error('Failed to fetch and update cache:', error); + }); + + // Return cached response immediately if available + return cachedResponse || fetchPromise; +} + +// Fetch event - handle all fetch requests +self.addEventListener('fetch', event => { + // Skip non-GET requests and browser extensions + if (event.request.method !== 'GET' || + event.request.url.startsWith('chrome-extension://') || + event.request.url.includes('extension://')) { + return; + } + + // Get the appropriate strategy for this URL + const strategy = getStrategyForUrl(event.request.url); + + // Apply the selected strategy + switch (strategy) { + case CACHE_STRATEGIES.NETWORK_FIRST: + event.respondWith(networkFirstStrategy(event.request)); + break; + + case CACHE_STRATEGIES.CACHE_FIRST: + event.respondWith(cacheFirstStrategy(event.request)); + break; + + case CACHE_STRATEGIES.STALE_WHILE_REVALIDATE: + event.respondWith(staleWhileRevalidateStrategy(event.request)); + break; + + case CACHE_STRATEGIES.CACHE_ONLY: + event.respondWith(caches.match(event.request)); + break; + + case CACHE_STRATEGIES.NETWORK_ONLY: + event.respondWith(fetch(event.request)); + break; + + default: + // Default to network first + event.respondWith(networkFirstStrategy(event.request)); + } +}); + +// Handle offline fallback +self.addEventListener('fetch', event => { + // Only handle HTML navigation requests that fail + if (event.request.mode === 'navigate') { + event.respondWith( + fetch(event.request) + .catch(() => { + // If fetch fails, return the offline page + return caches.match('/offline.html') + .then(response => { + return response || new Response( + '

Offline

You are currently offline.

', + { + headers: { 'Content-Type': 'text/html' } + } + ); + }); + }) + ); + } +}); + +// Listen for messages from the client +self.addEventListener('message', event => { + if (event.data && event.data.type === 'SKIP_WAITING') { + self.skipWaiting(); + } +}); diff --git a/src/components/common/OptimizedImage.astro b/src/components/common/OptimizedImage.astro index 9b9c2be..f08c7a2 100644 --- a/src/components/common/OptimizedImage.astro +++ b/src/components/common/OptimizedImage.astro @@ -8,6 +8,11 @@ interface Props { width?: number; height?: number; class?: string; + sizes?: string; + loading?: 'eager' | 'lazy'; + decoding?: 'sync' | 'async' | 'auto'; + format?: 'webp' | 'avif' | 'png' | 'jpeg' | 'jpg'; + quality?: number; } const { @@ -15,8 +20,16 @@ const { alt, width, height, - class: className = '' + class: className = '', + sizes = '(min-width: 1024px) 1024px, 100vw', + loading = 'lazy', + decoding = 'async', + format = 'webp', + quality = 80 } = Astro.props; + +// Determine if this is likely an above-the-fold image based on props +const isAboveFold = loading === 'eager'; --- {typeof src === 'string' ? ( @@ -26,38 +39,39 @@ const { width={width} height={height} class={className} + loading={loading} + decoding={decoding} /> ) : ( - - {/* Use direct component with conditional attributes to avoid TypeScript errors */} - {width && height ? ( - {alt} - ) : width ? ( - {alt} - ) : height ? ( - {alt} - ) : ( - {alt} - )} - + {alt} )} + + diff --git a/src/components/common/ResponsiveImage.astro b/src/components/common/ResponsiveImage.astro new file mode 100644 index 0000000..56f7aac --- /dev/null +++ b/src/components/common/ResponsiveImage.astro @@ -0,0 +1,248 @@ +--- +import { Image } from 'astro:assets'; +import type { ImageMetadata } from 'astro'; + +/** + * ResponsiveImage component with advanced features for optimal performance + * @component + */ +interface Props { + /** + * The image source (either an imported image or a URL) + */ + src: ImageMetadata | string; + + /** + * Alternative text for the image + */ + alt: string; + + /** + * Base width of the image + */ + width?: number; + + /** + * Base height of the image + */ + height?: number; + + /** + * CSS class to apply to the image + */ + class?: string; + + /** + * Sizes attribute for responsive images + * @default "(min-width: 1280px) 1280px, (min-width: 1024px) 1024px, (min-width: 768px) 768px, 100vw" + */ + sizes?: string; + + /** + * Loading strategy + * @default "lazy" + */ + loading?: 'eager' | 'lazy'; + + /** + * Decoding strategy + * @default "async" + */ + decoding?: 'sync' | 'async' | 'auto'; + + /** + * Image format + * @default "auto" + */ + format?: 'webp' | 'avif' | 'png' | 'jpeg' | 'jpg' | 'auto'; + + /** + * Image quality (1-100) + * @default 80 + */ + quality?: number; + + /** + * Whether to add a blur-up effect + * @default false + */ + blurUp?: boolean; + + /** + * Whether this is a priority image (above the fold) + * @default false + */ + priority?: boolean; + + /** + * Breakpoints for responsive images + * @default [640, 768, 1024, 1280] + */ + breakpoints?: number[]; + + /** + * Whether to use art direction (different images for different breakpoints) + * @default false + */ + artDirected?: boolean; + + /** + * Mobile image source (for art direction) + */ + mobileSrc?: ImageMetadata | string; + + /** + * Tablet image source (for art direction) + */ + tabletSrc?: ImageMetadata | string; + + /** + * Desktop image source (for art direction) + */ + desktopSrc?: ImageMetadata | string; +} + +const { + src, + alt, + width, + height, + class: className = '', + sizes = '(min-width: 1280px) 1280px, (min-width: 1024px) 1024px, (min-width: 768px) 768px, 100vw', + loading: propLoading, + decoding = 'async', + format = 'auto', + quality = 80, + blurUp = false, + priority = false, + breakpoints = [640, 768, 1024, 1280], + artDirected = false, + mobileSrc, + tabletSrc, + desktopSrc +} = 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)}`; + +// 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;' : ''; +--- + +{artDirected ? ( + + {/* Mobile image */} + + + {/* Tablet image */} + + + {/* Desktop image */} + + + {/* Fallback image */} + {isStringSource ? ( + {alt} + ) : ( + {alt} + )} + +) : ( + /* Standard responsive image */ + isStringSource ? ( + {alt} + ) : ( + {alt} + ) +)} + +{blurUp && ( + +)} + + diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro index 12255e2..2982f93 100644 --- a/src/layouts/Layout.astro +++ b/src/layouts/Layout.astro @@ -38,6 +38,23 @@ import "../styles/global.css"; document.documentElement.classList.add('theme-loaded'); })(); + + + + @@ -52,6 +69,7 @@ import "../styles/global.css"; + @@ -60,14 +78,14 @@ import "../styles/global.css"; - + @@ -79,9 +97,10 @@ import "../styles/global.css"; type="font/woff2" crossorigin /> + - - + +