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>
+
Binary file mrjunejune/src/public/icon-192.png has changed
Binary file mrjunejune/src/public/icon-512.png has changed
Binary file mrjunejune/src/public/icon-maskable-512.png has changed
--- /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))
+        );
+      })
+    );
+  }
+});