Mercurial
changeset 209:3b47e82ac57e
[MrJuneJune] PWA updates.
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Sun, 15 Feb 2026 15:43:26 -0800 |
| parents | 5d3e116dd745 |
| children | 0abed117e623 |
| files | mrjunejune/PWA_SETUP.md mrjunejune/generate-icons.sh mrjunejune/src/base.css mrjunejune/src/offline.html mrjunejune/src/parts/base_head.html mrjunejune/src/public/icon-192.png mrjunejune/src/public/icon-512.png mrjunejune/src/public/icon-maskable-512.png mrjunejune/src/public/manifest.json mrjunejune/src/public/pwa-register.js mrjunejune/src/public/sw.js |
| diffstat | 11 files changed, 564 insertions(+), 0 deletions(-) [+] |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/PWA_SETUP.md Sun Feb 15 15:43:26 2026 -0800 @@ -0,0 +1,126 @@ +# PWA Setup Guide + +Your site is now configured as a Progressive Web App! 🎉 + +## What's Been Added + +1. **manifest.json** - App configuration +2. **sw.js** - Service worker for caching and offline support +3. **pwa-register.js** - Service worker registration +4. **offline.html** - Offline fallback page +5. **Updated base_head.html** - Links to manifest and PWA script + +## Missing: App Icons + +You need to create PNG icons from your SVG. Run these commands: + +### Using ImageMagick (recommended): + +```bash +cd src/public + +# 192x192 icon +convert epi_all_colors.svg -resize 192x192 -background none icon-192.png + +# 512x512 icon +convert epi_all_colors.svg -resize 512x512 -background none icon-512.png + +# Maskable icon (512x512 with safe zone) +convert epi_all_colors.svg -resize 409x409 -background none -gravity center -extent 512x512 icon-maskable-512.png +``` + +### Or using Inkscape: + +```bash +inkscape epi_all_colors.svg --export-type=png --export-filename=icon-192.png -w 192 -h 192 +inkscape epi_all_colors.svg --export-type=png --export-filename=icon-512.png -w 512 -h 512 +inkscape epi_all_colors.svg --export-type=png --export-filename=icon-maskable-512.png -w 512 -h 512 +``` + +### Or use an online tool: +- https://realfavicongenerator.net/ +- https://www.pwabuilder.com/ + +## Optional: Screenshots (for better app install experience) + +Create screenshots of your site: + +```bash +# Mobile screenshot (540x720) +screenshot-mobile.png + +# Desktop screenshot (1280x720) +screenshot-desktop.png +``` + +You can use browser DevTools to capture these, or remove the `screenshots` section from manifest.json if you don't want them. + +## Testing Your PWA + +1. **Serve over HTTPS** - PWAs require HTTPS (localhost works for testing) +2. **Open Chrome DevTools** → Application tab → Manifest +3. **Check Service Worker** → Application tab → Service Workers +4. **Lighthouse Audit** → Run PWA audit to see score + +## Install Prompt + +When users visit your site, they'll see an "Install App" button in the bottom-right corner for 10 seconds. They can: +- Click it to install immediately +- Use browser menu: "Install MrJuneJune" or "Add to Home Screen" + +## Features + +✅ **Offline Support** - Caches pages, CSS, JS, fonts, images +✅ **App Shortcuts** - Quick access to Blog and Notes +✅ **Install Prompt** - Automatic install button +✅ **Auto-updates** - Service worker updates on reload +✅ **Fast Loading** - Cached resources load instantly + +## Customization + +Edit `manifest.json` to change: +- `theme_color` - App theme color +- `background_color` - Splash screen color +- `display` - `standalone`, `fullscreen`, `minimal-ui`, or `browser` +- `shortcuts` - App shortcut menu items + +Edit `sw.js` to change: +- `CACHE_VERSION` - Increment to force cache refresh +- `STATIC_CACHE` - Files to cache immediately +- Caching strategy (currently: cache-first with network fallback) + +## Testing on Mobile + +### Android: +1. Open Chrome +2. Visit your site +3. Tap menu → "Install app" or "Add to Home Screen" + +### iOS (Safari): +1. Open Safari +2. Tap Share button +3. Tap "Add to Home Screen" + +Note: iOS has limited PWA support (no install prompt, limited background features) + +## Debugging + +Check console for: +- `[PWA]` - Registration events +- `[SW]` - Service worker caching events + +Clear cache: +```js +navigator.serviceWorker.controller.postMessage({ type: 'CLEAR_CACHE' }); +``` + +## Next Steps + +1. Generate the PNG icons (see commands above) +2. Test on HTTPS +3. Run Lighthouse audit +4. Deploy and test on mobile device +5. Consider adding: + - Push notifications + - Background sync + - Share target API
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/generate-icons.sh Sun Feb 15 15:43:26 2026 -0800 @@ -0,0 +1,42 @@ +#!/bin/bash + +# Generate PWA icons from SVG +# Requires ImageMagick: apt-get install imagemagick + +cd "$(dirname "$0")/src/public" + +if ! command -v convert &> /dev/null; then + echo "❌ ImageMagick not found. Please install it:" + echo " Ubuntu/Debian: sudo apt-get install imagemagick" + echo " macOS: brew install imagemagick" + echo "" + echo "Or use an online tool: https://realfavicongenerator.net/" + exit 1 +fi + +echo "🎨 Generating PWA icons from epi_all_colors.svg..." + +# 192x192 icon +echo " → Creating icon-192.png..." +convert epi_all_colors.svg -resize 192x192 -background none icon-192.png + +# 512x512 icon +echo " → Creating icon-512.png..." +convert epi_all_colors.svg -resize 512x512 -background none icon-512.png + +# Maskable icon (512x512 with safe zone - 80% of size) +echo " → Creating icon-maskable-512.png..." +convert epi_all_colors.svg -resize 409x409 -background none -gravity center -extent 512x512 icon-maskable-512.png + +echo "" +echo "✅ Icons generated successfully!" +echo "" +echo "Generated files:" +echo " - icon-192.png" +echo " - icon-512.png" +echo " - icon-maskable-512.png" +echo "" +echo "Next steps:" +echo " 1. Verify icons look good: ls -lh src/public/icon-*.png" +echo " 2. Test PWA: Open site in Chrome → DevTools → Application → Manifest" +echo " 3. Run Lighthouse audit to check PWA score"
--- a/mrjunejune/src/base.css Sun Feb 15 12:33:54 2026 -0800 +++ b/mrjunejune/src/base.css Sun Feb 15 15:43:26 2026 -0800 @@ -359,3 +359,40 @@ } +/* pwa icon */ +#pwa-install-btn { + position: fixed; + bottom: 20px; + right: 20px; + padding: 12px 24px 12px 36px; + background: #000000; + color: #eeffee; + border: none; + border-radius: 8px; + cursor: pointer; + font-size: 14px; + font-weight: bold; + box-shadow: 0 4px 12px rgba(0,0,0,0.2); + z-index: 1000; + font-family: inherit; +} + +#pwa-install-btn.with-icon::before { + content: ""; + position: absolute; + left: 14px; + top: 50%; + transform: translateY(-50%); + width: 18px; + height: 18px; + + /* Masked icon */ + -webkit-mask: url('/public/paw.svg') no-repeat center; + mask: url('/public/paw.svg') no-repeat center; + mask-size: contain; + mask-repeat: no-repeat; + + /* Icon color = button text color */ + background-color: currentColor; +} +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/src/offline.html Sun Feb 15 15:43:26 2026 -0800 @@ -0,0 +1,61 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Offline - MrJuneJune</title> + <style> + body { + font-family: system-ui, -apple-system, sans-serif; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 100vh; + margin: 0; + padding: 20px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + text-align: center; + } + + h1 { + font-size: 3em; + margin: 0 0 0.5em 0; + } + + p { + font-size: 1.2em; + margin: 0 0 2em 0; + opacity: 0.9; + } + + button { + padding: 12px 32px; + font-size: 1em; + background: white; + color: #667eea; + border: none; + border-radius: 8px; + cursor: pointer; + font-weight: bold; + transition: transform 0.2s; + } + + button:hover { + transform: scale(1.05); + } + + .icon { + font-size: 5em; + margin-bottom: 0.3em; + } + </style> + </head> + <body> + <div class="icon">📡</div> + <h1>You're Offline</h1> + <p>It looks like you've lost your internet connection.<br>Don't worry, some cached pages might still work!</p> + <button onclick="window.location.reload()">Try Again</button> + </body> +</html>
--- a/mrjunejune/src/parts/base_head.html Sun Feb 15 12:33:54 2026 -0800 +++ b/mrjunejune/src/parts/base_head.html Sun Feb 15 15:43:26 2026 -0800 @@ -1,6 +1,9 @@ <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> +<meta name="theme-color" content="#2337ff"> <link rel="icon" type="image/svg+xml" href="/public/epi_all_colors.svg"> +<link rel="manifest" href="/public/manifest.json"> +<link rel="apple-touch-icon" href="/public/epi_all_colors.svg"> <link rel="preload" href="/public/fonts/Roboto-Regular.ttf" as="font" crossorigin> <link rel="preload" href="/public/fonts/Roboto-Thin.ttf"as="font" crossorigin> @@ -16,3 +19,5 @@ <link rel="preload" href="/base.css" as="style" /> <link rel="stylesheet" href="/base.css" /> +<script src="/public/pwa-register.js" defer></script> +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/src/public/manifest.json Sun Feb 15 15:43:26 2026 -0800 @@ -0,0 +1,67 @@ +{ + "name": "MrJuneJune", + "short_name": "MrJuneJune", + "description": "Personal website and blog of Juntae (June)", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#2337ff", + "orientation": "portrait-primary", + "icons": [ + { + "src": "/public/epi_all_colors.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + }, + { + "src": "/public/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/public/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/public/icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "screenshots": [ + { + "src": "/public/screenshot-mobile.png", + "sizes": "540x720", + "type": "image/png", + "form_factor": "narrow" + }, + { + "src": "/public/screenshot-desktop.png", + "sizes": "1280x720", + "type": "image/png", + "form_factor": "wide" + } + ], + "categories": ["lifestyle", "productivity"], + "shortcuts": [ + { + "name": "Blog", + "short_name": "Blog", + "description": "Read latest blog posts", + "url": "/blog", + "icons": [{ "src": "/public/epi_all_colors.svg", "sizes": "192x192" }] + }, + { + "name": "Notes", + "short_name": "Notes", + "description": "Access your notes", + "url": "/notes", + "icons": [{ "src": "/public/epi_all_colors.svg", "sizes": "192x192" }] + } + ] +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/src/public/pwa-register.js Sun Feb 15 15:43:26 2026 -0800 @@ -0,0 +1,88 @@ +// PWA Service Worker Registration +if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker + .register('/public/sw.js') + .then((registration) => { + console.log('[PWA] Service Worker registered:', registration.scope); + + // Check for updates periodically + setInterval(() => { + registration.update(); + }, 60000); // Check every minute + + // Handle updates + registration.addEventListener('updatefound', () => { + const newWorker = registration.installing; + newWorker.addEventListener('statechange', () => { + if (newWorker.state === 'installed' && navigator.serviceWorker.controller) { + // New content available, show update notification + if (confirm('New version available! Reload to update?')) { + newWorker.postMessage({ type: 'SKIP_WAITING' }); + window.location.reload(); + } + } + }); + }); + }) + .catch((error) => { + console.error('[PWA] Service Worker registration failed:', error); + }); + + // Handle service worker updates + let refreshing = false; + navigator.serviceWorker.addEventListener('controllerchange', () => { + if (!refreshing) { + refreshing = true; + window.location.reload(); + } + }); + }); +} + +// Add install prompt handler +let deferredPrompt; + +window.addEventListener('beforeinstallprompt', (e) => { + e.preventDefault(); + deferredPrompt = e; + + showInstallPromotion(); +}); + +function showInstallPromotion() { + // Create an install button if it doesn't exist + if (document.getElementById('pwa-install-btn')) return; + + const installBtn = document.createElement('button'); + installBtn.classList.add('with-icon'); + installBtn.id = 'pwa-install-btn'; + installBtn.textContent = 'Install as App'; + + installBtn.addEventListener('click', async () => { + if (!deferredPrompt) return; + + deferredPrompt.prompt(); + const { outcome } = await deferredPrompt.userChoice; + console.log(`[PWA] User response: ${outcome}`); + + deferredPrompt = null; + installBtn.remove(); + }); + + document.body.appendChild(installBtn); + + // setTimeout(() => { + // installBtn.style.opacity = '0'; + // installBtn.style.transition = 'opacity 0.3s'; + // setTimeout(() => installBtn.remove(), 300); + // }, 10000); +} + +window.addEventListener('appinstalled', () => { + console.log('[PWA] App installed successfully!'); + deferredPrompt = null; + + const installBtn = document.getElementById('pwa-install-btn'); + if (installBtn) installBtn.remove(); +});
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/src/public/sw.js Sun Feb 15 15:43:26 2026 -0800 @@ -0,0 +1,138 @@ +// Service Worker for MrJuneJune PWA +const CACHE_VERSION = 'v1'; +const CACHE_NAME = `mrjunejune-${CACHE_VERSION}`; + +// Files to cache immediately on install +const STATIC_CACHE = [ + '/', + '/base.css', + '/public/epi_all_colors.svg', + '/public/fonts/Roboto-Regular.ttf', + '/public/fonts/Roboto-Thin.ttf', + '/public/fonts/more-sugar.regular.otf', + '/public/fonts/more-sugar.thin.otf', +]; + +// Install event - cache static assets +self.addEventListener('install', (event) => { + console.log('[SW] Installing service worker...'); + + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => { + console.log('[SW] Caching static assets'); + return cache.addAll(STATIC_CACHE); + }).then(() => { + console.log('[SW] Skip waiting'); + return self.skipWaiting(); + }) + ); +}); + +// Activate event - clean up old caches +self.addEventListener('activate', (event) => { + console.log('[SW] Activating service worker...'); + + event.waitUntil( + caches.keys().then((cacheNames) => { + return Promise.all( + cacheNames.map((cacheName) => { + if (cacheName !== CACHE_NAME) { + console.log('[SW] Deleting old cache:', cacheName); + return caches.delete(cacheName); + } + }) + ); + }).then(() => { + console.log('[SW] Claiming clients'); + return self.clients.claim(); + }) + ); +}); + +// Fetch event - serve from cache, fallback to network +self.addEventListener('fetch', (event) => { + const { request } = event; + const url = new URL(request.url); + + // Skip non-GET requests + if (request.method !== 'GET') { + return; + } + + // Skip chrome-extension and other non-http(s) requests + if (!url.protocol.startsWith('http')) { + return; + } + + // Skip API calls and media uploads (always go to network) + if (url.pathname.startsWith('/api/')) { + return; + } + + event.respondWith( + caches.match(request).then((cachedResponse) => { + if (cachedResponse) { + console.log('[SW] Serving from cache:', url.pathname); + return cachedResponse; + } + + // Not in cache, fetch from network + return fetch(request).then((networkResponse) => { + // Only cache successful responses + if (!networkResponse || networkResponse.status !== 200 || networkResponse.type === 'error') { + return networkResponse; + } + + // Cache specific file types + const shouldCache = + url.pathname.endsWith('.css') || + url.pathname.endsWith('.js') || + url.pathname.endsWith('.svg') || + url.pathname.endsWith('.png') || + url.pathname.endsWith('.jpg') || + url.pathname.endsWith('.webp') || + url.pathname.endsWith('.woff') || + url.pathname.endsWith('.woff2') || + url.pathname.endsWith('.ttf') || + url.pathname.endsWith('.otf') || + url.pathname.startsWith('/blog/') || + url.pathname.startsWith('/notes/') || + url.pathname === '/'; + + if (shouldCache) { + const responseToCache = networkResponse.clone(); + caches.open(CACHE_NAME).then((cache) => { + console.log('[SW] Caching new resource:', url.pathname); + cache.put(request, responseToCache); + }); + } + + return networkResponse; + }).catch((error) => { + console.log('[SW] Fetch failed:', error); + + // Return offline page for HTML requests + if (request.headers.get('Accept').includes('text/html')) { + return caches.match('/offline.html'); + } + }); + }) + ); +}); + +// Handle messages from the client +self.addEventListener('message', (event) => { + if (event.data && event.data.type === 'SKIP_WAITING') { + self.skipWaiting(); + } + + if (event.data && event.data.type === 'CLEAR_CACHE') { + event.waitUntil( + caches.keys().then((cacheNames) => { + return Promise.all( + cacheNames.map((cacheName) => caches.delete(cacheName)) + ); + }) + ); + } +});