diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..04aed77 --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,25 @@ +{ + "name": "Cris Charles Garcia | Full-Stack Developer & E-Business Card", + "short_name": "Cris Charles", + "description": "Digital portfolio and e-business card of Cris Charles Garcia. Full-stack developer and Cisco certified specialist.", + "start_url": "/", + "display": "standalone", + "background_color": "#090a0f", + "theme_color": "#090a0f", + "orientation": "any", + "categories": ["portfolio", "business", "utilities"], + "icons": [ + { + "src": "/logo.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/logo.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..f3f541f --- /dev/null +++ b/public/sw.js @@ -0,0 +1,99 @@ +const CACHE_NAME = 'portfolio-cache-v1'; + +// Core assets to precache immediately on install +const PRECACHE_ASSETS = [ + '/', + '/manifest.webmanifest', + '/logo.png', + '/favicon.ico', + '/CV-Cris-Charles-Garcia.pdf' +]; + +// Install: precache shell +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => { + return cache.addAll(PRECACHE_ASSETS).catch((err) => { + console.warn('SW: Precache partial failure (non-critical):', err); + }); + }).then(() => self.skipWaiting()) + ); +}); + +// Activate: clean up old cache versions +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((cacheNames) => { + return Promise.all( + cacheNames + .filter((name) => name !== CACHE_NAME) + .map((name) => caches.delete(name)) + ); + }).then(() => self.clients.claim()) + ); +}); + +// Fetch: Network-First for HTML navigation, Stale-While-Revalidate for static assets +self.addEventListener('fetch', (event) => { + const { request } = event; + const url = new URL(request.url); + + // Skip non-GET requests and browser extensions + if (request.method !== 'GET' || !url.protocol.startsWith('http')) { + return; + } + + // 1. Navigation requests (HTML pages): Network-First with Cache Fallback + if (request.mode === 'navigate') { + event.respondWith( + fetch(request) + .then((networkResponse) => { + if (networkResponse && networkResponse.status === 200) { + const responseClone = networkResponse.clone(); + caches.open(CACHE_NAME).then((cache) => { + cache.put(request, responseClone); + }); + } + return networkResponse; + }) + .catch(async () => { + // Offline fallback + const cachedResponse = await caches.match(request); + if (cachedResponse) { + return cachedResponse; + } + // Fallback to home page root if specific subpage isn't cached + const rootCached = await caches.match('/'); + if (rootCached) { + return rootCached; + } + return new Response( + 'Offline - Cris Charles

📶 Offline Mode

You are currently offline. Please reconnect to view new updates or visit previously cached pages.

Return to Home
', + { headers: { 'Content-Type': 'text/html' } } + ); + }) + ); + return; + } + + // 2. Static Assets (CSS, JS, WebP, SVG, Fonts, PNG): Stale-While-Revalidate + event.respondWith( + caches.match(request).then((cachedResponse) => { + const fetchPromise = fetch(request) + .then((networkResponse) => { + if (networkResponse && networkResponse.status === 200) { + const responseClone = networkResponse.clone(); + caches.open(CACHE_NAME).then((cache) => { + cache.put(request, responseClone); + }); + } + return networkResponse; + }) + .catch(() => { + // Network failed, nothing extra to do if cached response is returned + }); + + return cachedResponse || fetchPromise; + }) + ); +}); diff --git a/src/components/NetworkStatus.tsx b/src/components/NetworkStatus.tsx new file mode 100644 index 0000000..85b2f46 --- /dev/null +++ b/src/components/NetworkStatus.tsx @@ -0,0 +1,67 @@ +import React, { useEffect, useState } from 'react'; + +export default function NetworkStatus() { + const [isOnline, setIsOnline] = useState(true); + const [showToast, setShowToast] = useState(false); + const [hasChanged, setHasChanged] = useState(false); + + useEffect(() => { + if (typeof window === 'undefined') return; + + setIsOnline(navigator.onLine); + if (!navigator.onLine) { + setShowToast(true); + setHasChanged(true); + } + + const handleOnline = () => { + setIsOnline(true); + setShowToast(true); + setHasChanged(true); + const timer = setTimeout(() => { + setShowToast(false); + }, 3500); + return () => clearTimeout(timer); + }; + + const handleOffline = () => { + setIsOnline(false); + setShowToast(true); + setHasChanged(true); + }; + + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + + return () => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + }; + }, []); + + if (!showToast || !hasChanged) return null; + + return ( +
+ + + {isOnline + ? 'Connected • Fresh updates synced' + : 'Offline Mode • Browsing saved portfolio'} + +
+ ); +} diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro index d11c5ba..b66fed0 100644 --- a/src/layouts/Layout.astro +++ b/src/layouts/Layout.astro @@ -6,6 +6,7 @@ import SpotlightLens from '../components/SpotlightLens'; import PortfolioAssistantFAB from '../components/PortfolioAssistantFAB'; import Analytics from '@vercel/analytics/astro'; import SEO from '../components/SEO.astro'; +import NetworkStatus from '../components/NetworkStatus'; interface Props { title?: string; @@ -31,7 +32,13 @@ const isRootPage = Astro.url.pathname === '/'; - + + + + + + + @@ -88,6 +95,17 @@ const isRootPage = Astro.url.pathname === '/'; document.documentElement.dataset.tier = tier; })(); + + + @@ -98,6 +116,7 @@ const isRootPage = Astro.url.pathname === '/'; +