Merge pull request #15 from derekslenk/feature/pwa-support

Add Progressive Web App functionality
This commit is contained in:
Deco Vander 2025-07-06 00:49:51 -04:00 committed by GitHub
commit 457875ecef
21 changed files with 928 additions and 16 deletions

View file

@ -138,8 +138,15 @@ CREATE TABLE locations (
**Profanity Database (`profanity.db`)**:
Managed by the `ProfanityFilter` class for content moderation.
### Frontend (Progressive Enhancement)
The application uses progressive enhancement to work with and without JavaScript:
### Frontend (Progressive Web App + Progressive Enhancement)
The application is a full Progressive Web App with offline capabilities and progressive enhancement:
**PWA Features:**
- **public/manifest.json**: Web app manifest for installation
- **public/sw.js**: Service worker for offline functionality and caching
- **public/icons/**: Complete icon set for all device sizes (72px to 512px)
- **public/offline.html**: Offline fallback page when network is unavailable
- **PWA Meta Tags**: Added to all HTML files for proper app behavior
**JavaScript-Enhanced Experience:**
- **public/app.js**: Main implementation using Leaflet.js
@ -158,6 +165,11 @@ The application uses progressive enhancement to work with and without JavaScript
- **Static map generation**: Auto-fitted Mapbox static images showing all report locations
- **Progressive enhancement**: noscript tags and "Basic View" button for accessibility
**Offline Capabilities:**
- **Service Worker Caching**: Essential files cached for offline access
- **Offline Page**: Custom offline experience with automatic reconnection
- **Install Prompt**: Automatic PWA installation prompts on compatible devices
### API Endpoints
**Public endpoints:**
@ -202,15 +214,17 @@ SCSS files are in `src/scss/`:
### Key Design Patterns
1. **TypeScript-First Architecture**: Full type safety with strict type checking
2. **Progressive Enhancement**: Works completely without JavaScript via server-side rendering
3. **Security-by-Design**: Rate limiting, input validation, and authentication built into core routes
4. **Modular Route Architecture**: Routes accept dependencies as parameters for testability
5. **Dual Database Design**: Separate databases for application data and content moderation
6. **Type-Safe Database Operations**: All database interactions use typed models
7. **Comprehensive Testing**: 125+ tests covering units, integration, and security scenarios
8. **Graceful Degradation**: Fallback geocoding providers and error handling
9. **Automated Maintenance**: Cron-based cleanup of expired reports
10. **Accessibility-First**: noscript fallbacks and server-side static map generation
2. **Progressive Web App**: Installable, offline-capable with service worker caching
3. **Progressive Enhancement**: Works completely without JavaScript via server-side rendering
4. **Security-by-Design**: Rate limiting, input validation, and authentication built into core routes
5. **Modular Route Architecture**: Routes accept dependencies as parameters for testability
6. **Dual Database Design**: Separate databases for application data and content moderation
7. **Type-Safe Database Operations**: All database interactions use typed models
8. **Comprehensive Testing**: 125+ tests covering units, integration, and security scenarios
9. **Graceful Degradation**: Fallback geocoding providers and error handling
10. **Automated Maintenance**: Cron-based cleanup of expired reports
11. **Accessibility-First**: noscript fallbacks and server-side static map generation
12. **Offline-First Design**: Service worker caching with automatic updates
### Deployment
- Automated deployment script for Debian 12 ARM64 in `scripts/deploy.sh`

View file

@ -6,6 +6,7 @@ A community-driven web application for tracking winter road conditions and icy h
- 🗺️ **Interactive Map** - Real-time location tracking centered on Grand Rapids
- ⚡ **Fast Geocoding** - Lightning-fast address lookup with Mapbox API
- 📱 **Progressive Web App** - Install on mobile home screen, works offline
- 🚫 **JavaScript-Free Mode** - Complete functionality without JavaScript via server-side rendering
- 🖼️ **Static Maps** - Auto-generated Mapbox static images for non-JS users
- 🔄 **Auto-Expiration** - Reports automatically removed after 48 hours
@ -172,10 +173,31 @@ See `docs/deployment.md` for detailed manual deployment instructions.
### API Documentation
Interactive API documentation available at `/api-docs` when running the server.
## PWA Installation
### Mobile Devices
1. Open the app in your mobile browser
2. Look for the "Add to Home Screen" prompt
3. Tap "Add" to install the app on your home screen
4. The app will work offline and behave like a native app
### Desktop Browsers
1. Visit the app in Chrome, Edge, or Safari
2. Look for the install icon in the address bar
3. Click "Install" to add the app to your desktop
4. Launch from your applications folder or start menu
### PWA Features
- **Offline Access:** View cached reports when disconnected
- **App-like Experience:** Standalone window, no browser UI
- **Home Screen Icon:** Quick access like native apps
- **Automatic Updates:** Always stay current when online
## Technology Stack
- **Backend:** Node.js, Express.js, SQLite, TypeScript
- **Frontend:** Progressive Enhancement (Vanilla JavaScript + Server-side rendering)
- **Frontend:** Progressive Web App with Progressive Enhancement
- **PWA:** Installable, offline-capable, service worker caching
- **Enhanced:** Leaflet.js interactive maps with real-time updates
- **Fallback:** Server-side HTML tables with static Mapbox images
- **Geocoding:** Mapbox API (with Nominatim fallback)

View file

@ -4,8 +4,24 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Great Lakes Ice Report Admin</title>
<link rel="icon" type="image/svg+xml" href="https://iceymi.b-cdn.net/favicon.svg">
<!-- PWA Meta Tags -->
<meta name="description" content="Admin panel for Great Lakes Ice Report - manage winter road condition reports">
<meta name="theme-color" content="#2196F3">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="Ice Report Admin">
<!-- PWA Manifest -->
<link rel="manifest" href="/manifest.json">
<!-- Icons -->
<link rel="icon" type="image/svg+xml" href="/icons/favicon.svg">
<link rel="icon" type="image/x-icon" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMiAzMiIgd2lkdGg9IjMyIiBoZWlnaHQ9IjMyIj4KICA8ZGVmcz4KICAgIDxzdHlsZT4KICAgICAgLnNub3dmbGFrZSB7IGZpbGw6ICMyMTk2RjM7IH0KICAgICAgLmNlbnRlciB7IGZpbGw6ICMxOTc2RDI7IH0KICAgIDwvc3R5bGU+CiAgPC9kZWZzPgogIAogIDxnIGNsYXNzPSJzbm93Zmxha2UiPgogICAgPHJlY3QgeD0iMTUiIHk9IjIiIHdpZHRoPSIyIiBoZWlnaHQ9IjI4IiAvPgogICAgPHJlY3QgeD0iMiIgeT0iMTUiIHdpZHRoPSIyOCIgaGVpZ2h0PSIyIiAvPgogICAgPHJlY3QgeD0iMTUiIHk9IjIiIHdpZHRoPSIyIiBoZWlnaHQ9IjI4IiB0cmFuc2Zvcm09InJvdGF0ZSg0NSAxNiAxNikiIC8+CiAgICA8cmVjdCB4PSIxNSIgeT0iMiIgd2lkdGg9IjIiIGhlaWdodD0iMjgiIHRyYW5zZm9ybT0icm90YXRlKC00NSAxNiAxNikiIC8+CiAgICA8cG9seWdvbiBwb2ludHM9IjE2LDIgMTQsNiAxOCw2IiAvPgogICAgPHBvbHlnb24gcG9pbnRzPSIxNiwzMCAxNCwyNiAxOCwyNiIgLz4KICAgIDxwb2x5Z29uIHBvaW50cz0iMiwxNiA2LDE0IDYsMTgiIC8+CiAgICA8cG9seWdvbiBwb2ludHM9IjMwLDE2IDI2LDE0IDI2LDE4IiAvPgogICAgPHBvbHlnb24gcG9pbnRzPSI2LjMsNi4zIDguNiw0IDkuOSw3LjciIHRyYW5zZm9ybT0icm90YXRlKDQ1IDE2IDE2KSIgLz4KICAgIDxwb2x5Z29uIHBvaW50cz0iMjUuNywyNS43IDIzLjQsMjggMjIuMSwyNC4zIiB0cmFuc2Zvcm09InJvdGF0ZSg0NSAxNiAxNikiIC8+CiAgICA8cG9seWdvbiBwb2ludHM9IjYuMywyNS43IDguNiwyOCA5LjksMjQuMyIgdHJhbnNmb3JtPSJyb3RhdGUoLTQ1IDE2IDE2KSIgLz4KICAgIDxwb2x5Z29uIHBvaW50cz0iMjUuNyw2LjMgMjMuNCw0IDIyLjEsNy43IiB0cmFuc2Zvcm09InJvdGF0ZSgtNDUgMTYgMTYpIiAvPgogIDwvZz4KICA8Y2lyY2xlIGN4PSIxNiIgY3k9IjE2IiByPSIzIiBjbGFzcz0iY2VudGVyIiAvPgo8L3N2Zz4K">
<!-- Apple Touch Icons -->
<link rel="apple-touch-icon" sizes="152x152" href="/icons/icon-152.svg">
<link rel="apple-touch-icon" sizes="180x180" href="/icons/icon-192.svg">
<link rel="stylesheet" href="style.css">
<script>
// Apply theme immediately to prevent flash

10
public/browserconfig.xml Normal file
View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/icons/icon-152.svg"/>
<square310x310logo src="/icons/icon-384.svg"/>
<TileColor>#2196F3</TileColor>
</tile>
</msapplication>
</browserconfig>

32
public/icons/favicon.svg Normal file
View file

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#2196F3;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1976D2;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Background circle -->
<circle cx="16" cy="16" r="16" fill="url(#bg)"/>
<!-- Ice crystal/snowflake icon -->
<g transform="translate(16, 16)">
<!-- Main cross -->
<line x1="-9.6" y1="0" x2="9.6" y2="0" stroke="white" stroke-width="2.56" stroke-linecap="round"/>
<line x1="0" y1="-9.6" x2="0" y2="9.6" stroke="white" stroke-width="2.56" stroke-linecap="round"/>
<!-- Diagonal lines -->
<line x1="-6.72" y1="-6.72" x2="6.72" y2="6.72" stroke="white" stroke-width="1.92" stroke-linecap="round"/>
<line x1="6.72" y1="-6.72" x2="-6.72" y2="6.72" stroke="white" stroke-width="1.92" stroke-linecap="round"/>
<!-- Small decorative elements -->
<circle cx="0" cy="0" r="1.28" fill="white"/>
<circle cx="4.8" cy="0" r="0.64" fill="white"/>
<circle cx="-4.8" cy="0" r="0.64" fill="white"/>
<circle cx="0" cy="4.8" r="0.64" fill="white"/>
<circle cx="0" cy="-4.8" r="0.64" fill="white"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

32
public/icons/icon-128.svg Normal file
View file

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#2196F3;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1976D2;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Background circle -->
<circle cx="64" cy="64" r="64" fill="url(#bg)"/>
<!-- Ice crystal/snowflake icon -->
<g transform="translate(64, 64)">
<!-- Main cross -->
<line x1="-38.4" y1="0" x2="38.4" y2="0" stroke="white" stroke-width="10.24" stroke-linecap="round"/>
<line x1="0" y1="-38.4" x2="0" y2="38.4" stroke="white" stroke-width="10.24" stroke-linecap="round"/>
<!-- Diagonal lines -->
<line x1="-26.88" y1="-26.88" x2="26.88" y2="26.88" stroke="white" stroke-width="7.68" stroke-linecap="round"/>
<line x1="26.88" y1="-26.88" x2="-26.88" y2="26.88" stroke="white" stroke-width="7.68" stroke-linecap="round"/>
<!-- Small decorative elements -->
<circle cx="0" cy="0" r="5.12" fill="white"/>
<circle cx="19.2" cy="0" r="2.56" fill="white"/>
<circle cx="-19.2" cy="0" r="2.56" fill="white"/>
<circle cx="0" cy="19.2" r="2.56" fill="white"/>
<circle cx="0" cy="-19.2" r="2.56" fill="white"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

32
public/icons/icon-144.svg Normal file
View file

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="144" height="144" viewBox="0 0 144 144" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#2196F3;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1976D2;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Background circle -->
<circle cx="72" cy="72" r="72" fill="url(#bg)"/>
<!-- Ice crystal/snowflake icon -->
<g transform="translate(72, 72)">
<!-- Main cross -->
<line x1="-43.199999999999996" y1="0" x2="43.199999999999996" y2="0" stroke="white" stroke-width="11.52" stroke-linecap="round"/>
<line x1="0" y1="-43.199999999999996" x2="0" y2="43.199999999999996" stroke="white" stroke-width="11.52" stroke-linecap="round"/>
<!-- Diagonal lines -->
<line x1="-30.24" y1="-30.24" x2="30.24" y2="30.24" stroke="white" stroke-width="8.64" stroke-linecap="round"/>
<line x1="30.24" y1="-30.24" x2="-30.24" y2="30.24" stroke="white" stroke-width="8.64" stroke-linecap="round"/>
<!-- Small decorative elements -->
<circle cx="0" cy="0" r="5.76" fill="white"/>
<circle cx="21.599999999999998" cy="0" r="2.88" fill="white"/>
<circle cx="-21.599999999999998" cy="0" r="2.88" fill="white"/>
<circle cx="0" cy="21.599999999999998" r="2.88" fill="white"/>
<circle cx="0" cy="-21.599999999999998" r="2.88" fill="white"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

32
public/icons/icon-152.svg Normal file
View file

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="152" height="152" viewBox="0 0 152 152" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#2196F3;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1976D2;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Background circle -->
<circle cx="76" cy="76" r="76" fill="url(#bg)"/>
<!-- Ice crystal/snowflake icon -->
<g transform="translate(76, 76)">
<!-- Main cross -->
<line x1="-45.6" y1="0" x2="45.6" y2="0" stroke="white" stroke-width="12.16" stroke-linecap="round"/>
<line x1="0" y1="-45.6" x2="0" y2="45.6" stroke="white" stroke-width="12.16" stroke-linecap="round"/>
<!-- Diagonal lines -->
<line x1="-31.919999999999998" y1="-31.919999999999998" x2="31.919999999999998" y2="31.919999999999998" stroke="white" stroke-width="9.12" stroke-linecap="round"/>
<line x1="31.919999999999998" y1="-31.919999999999998" x2="-31.919999999999998" y2="31.919999999999998" stroke="white" stroke-width="9.12" stroke-linecap="round"/>
<!-- Small decorative elements -->
<circle cx="0" cy="0" r="6.08" fill="white"/>
<circle cx="22.8" cy="0" r="3.04" fill="white"/>
<circle cx="-22.8" cy="0" r="3.04" fill="white"/>
<circle cx="0" cy="22.8" r="3.04" fill="white"/>
<circle cx="0" cy="-22.8" r="3.04" fill="white"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="192" height="192" viewBox="0 0 192 192" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#2196F3;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1976D2;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Background circle -->
<circle cx="96" cy="96" r="96" fill="url(#bg)"/>
<!-- Ice crystal/snowflake icon -->
<g transform="translate(96, 96)">
<!-- Main cross -->
<line x1="-46.08" y1="0" x2="46.08" y2="0" stroke="white" stroke-width="12.288" stroke-linecap="round"/>
<line x1="0" y1="-46.08" x2="0" y2="46.08" stroke="white" stroke-width="12.288" stroke-linecap="round"/>
<!-- Diagonal lines -->
<line x1="-32.256" y1="-32.256" x2="32.256" y2="32.256" stroke="white" stroke-width="9.216" stroke-linecap="round"/>
<line x1="32.256" y1="-32.256" x2="-32.256" y2="32.256" stroke="white" stroke-width="9.216" stroke-linecap="round"/>
<!-- Small decorative elements -->
<circle cx="0" cy="0" r="6.144" fill="white"/>
<circle cx="23.04" cy="0" r="3.072" fill="white"/>
<circle cx="-23.04" cy="0" r="3.072" fill="white"/>
<circle cx="0" cy="23.04" r="3.072" fill="white"/>
<circle cx="0" cy="-23.04" r="3.072" fill="white"/>
</g>
<!-- Safe zone indicator (invisible) -->
<circle cx="96" cy="96" r="76.80000000000001" fill="none" stroke="none"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

32
public/icons/icon-192.svg Normal file
View file

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="192" height="192" viewBox="0 0 192 192" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#2196F3;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1976D2;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Background circle -->
<circle cx="96" cy="96" r="96" fill="url(#bg)"/>
<!-- Ice crystal/snowflake icon -->
<g transform="translate(96, 96)">
<!-- Main cross -->
<line x1="-57.599999999999994" y1="0" x2="57.599999999999994" y2="0" stroke="white" stroke-width="15.36" stroke-linecap="round"/>
<line x1="0" y1="-57.599999999999994" x2="0" y2="57.599999999999994" stroke="white" stroke-width="15.36" stroke-linecap="round"/>
<!-- Diagonal lines -->
<line x1="-40.32" y1="-40.32" x2="40.32" y2="40.32" stroke="white" stroke-width="11.52" stroke-linecap="round"/>
<line x1="40.32" y1="-40.32" x2="-40.32" y2="40.32" stroke="white" stroke-width="11.52" stroke-linecap="round"/>
<!-- Small decorative elements -->
<circle cx="0" cy="0" r="7.68" fill="white"/>
<circle cx="28.799999999999997" cy="0" r="3.84" fill="white"/>
<circle cx="-28.799999999999997" cy="0" r="3.84" fill="white"/>
<circle cx="0" cy="28.799999999999997" r="3.84" fill="white"/>
<circle cx="0" cy="-28.799999999999997" r="3.84" fill="white"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

32
public/icons/icon-384.svg Normal file
View file

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="384" height="384" viewBox="0 0 384 384" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#2196F3;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1976D2;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Background circle -->
<circle cx="192" cy="192" r="192" fill="url(#bg)"/>
<!-- Ice crystal/snowflake icon -->
<g transform="translate(192, 192)">
<!-- Main cross -->
<line x1="-115.19999999999999" y1="0" x2="115.19999999999999" y2="0" stroke="white" stroke-width="30.72" stroke-linecap="round"/>
<line x1="0" y1="-115.19999999999999" x2="0" y2="115.19999999999999" stroke="white" stroke-width="30.72" stroke-linecap="round"/>
<!-- Diagonal lines -->
<line x1="-80.64" y1="-80.64" x2="80.64" y2="80.64" stroke="white" stroke-width="23.04" stroke-linecap="round"/>
<line x1="80.64" y1="-80.64" x2="-80.64" y2="80.64" stroke="white" stroke-width="23.04" stroke-linecap="round"/>
<!-- Small decorative elements -->
<circle cx="0" cy="0" r="15.36" fill="white"/>
<circle cx="57.599999999999994" cy="0" r="7.68" fill="white"/>
<circle cx="-57.599999999999994" cy="0" r="7.68" fill="white"/>
<circle cx="0" cy="57.599999999999994" r="7.68" fill="white"/>
<circle cx="0" cy="-57.599999999999994" r="7.68" fill="white"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#2196F3;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1976D2;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Background circle -->
<circle cx="256" cy="256" r="256" fill="url(#bg)"/>
<!-- Ice crystal/snowflake icon -->
<g transform="translate(256, 256)">
<!-- Main cross -->
<line x1="-122.88" y1="0" x2="122.88" y2="0" stroke="white" stroke-width="32.768" stroke-linecap="round"/>
<line x1="0" y1="-122.88" x2="0" y2="122.88" stroke="white" stroke-width="32.768" stroke-linecap="round"/>
<!-- Diagonal lines -->
<line x1="-86.016" y1="-86.016" x2="86.016" y2="86.016" stroke="white" stroke-width="24.576" stroke-linecap="round"/>
<line x1="86.016" y1="-86.016" x2="-86.016" y2="86.016" stroke="white" stroke-width="24.576" stroke-linecap="round"/>
<!-- Small decorative elements -->
<circle cx="0" cy="0" r="16.384" fill="white"/>
<circle cx="61.44" cy="0" r="8.192" fill="white"/>
<circle cx="-61.44" cy="0" r="8.192" fill="white"/>
<circle cx="0" cy="61.44" r="8.192" fill="white"/>
<circle cx="0" cy="-61.44" r="8.192" fill="white"/>
</g>
<!-- Safe zone indicator (invisible) -->
<circle cx="256" cy="256" r="204.8" fill="none" stroke="none"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

32
public/icons/icon-512.svg Normal file
View file

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#2196F3;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1976D2;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Background circle -->
<circle cx="256" cy="256" r="256" fill="url(#bg)"/>
<!-- Ice crystal/snowflake icon -->
<g transform="translate(256, 256)">
<!-- Main cross -->
<line x1="-153.6" y1="0" x2="153.6" y2="0" stroke="white" stroke-width="40.96" stroke-linecap="round"/>
<line x1="0" y1="-153.6" x2="0" y2="153.6" stroke="white" stroke-width="40.96" stroke-linecap="round"/>
<!-- Diagonal lines -->
<line x1="-107.52" y1="-107.52" x2="107.52" y2="107.52" stroke="white" stroke-width="30.72" stroke-linecap="round"/>
<line x1="107.52" y1="-107.52" x2="-107.52" y2="107.52" stroke="white" stroke-width="30.72" stroke-linecap="round"/>
<!-- Small decorative elements -->
<circle cx="0" cy="0" r="20.48" fill="white"/>
<circle cx="76.8" cy="0" r="10.24" fill="white"/>
<circle cx="-76.8" cy="0" r="10.24" fill="white"/>
<circle cx="0" cy="76.8" r="10.24" fill="white"/>
<circle cx="0" cy="-76.8" r="10.24" fill="white"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

32
public/icons/icon-72.svg Normal file
View file

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="72" height="72" viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#2196F3;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1976D2;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Background circle -->
<circle cx="36" cy="36" r="36" fill="url(#bg)"/>
<!-- Ice crystal/snowflake icon -->
<g transform="translate(36, 36)">
<!-- Main cross -->
<line x1="-21.599999999999998" y1="0" x2="21.599999999999998" y2="0" stroke="white" stroke-width="5.76" stroke-linecap="round"/>
<line x1="0" y1="-21.599999999999998" x2="0" y2="21.599999999999998" stroke="white" stroke-width="5.76" stroke-linecap="round"/>
<!-- Diagonal lines -->
<line x1="-15.12" y1="-15.12" x2="15.12" y2="15.12" stroke="white" stroke-width="4.32" stroke-linecap="round"/>
<line x1="15.12" y1="-15.12" x2="-15.12" y2="15.12" stroke="white" stroke-width="4.32" stroke-linecap="round"/>
<!-- Small decorative elements -->
<circle cx="0" cy="0" r="2.88" fill="white"/>
<circle cx="10.799999999999999" cy="0" r="1.44" fill="white"/>
<circle cx="-10.799999999999999" cy="0" r="1.44" fill="white"/>
<circle cx="0" cy="10.799999999999999" r="1.44" fill="white"/>
<circle cx="0" cy="-10.799999999999999" r="1.44" fill="white"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

32
public/icons/icon-96.svg Normal file
View file

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="96" height="96" viewBox="0 0 96 96" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#2196F3;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1976D2;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Background circle -->
<circle cx="48" cy="48" r="48" fill="url(#bg)"/>
<!-- Ice crystal/snowflake icon -->
<g transform="translate(48, 48)">
<!-- Main cross -->
<line x1="-28.799999999999997" y1="0" x2="28.799999999999997" y2="0" stroke="white" stroke-width="7.68" stroke-linecap="round"/>
<line x1="0" y1="-28.799999999999997" x2="0" y2="28.799999999999997" stroke="white" stroke-width="7.68" stroke-linecap="round"/>
<!-- Diagonal lines -->
<line x1="-20.16" y1="-20.16" x2="20.16" y2="20.16" stroke="white" stroke-width="5.76" stroke-linecap="round"/>
<line x1="20.16" y1="-20.16" x2="-20.16" y2="20.16" stroke="white" stroke-width="5.76" stroke-linecap="round"/>
<!-- Small decorative elements -->
<circle cx="0" cy="0" r="3.84" fill="white"/>
<circle cx="14.399999999999999" cy="0" r="1.92" fill="white"/>
<circle cx="-14.399999999999999" cy="0" r="1.92" fill="white"/>
<circle cx="0" cy="14.399999999999999" r="1.92" fill="white"/>
<circle cx="0" cy="-14.399999999999999" r="1.92" fill="white"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -4,8 +4,26 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Great Lakes Ice Report</title>
<link rel="icon" type="image/svg+xml" href="https://iceymi.b-cdn.net/favicon.svg">
<!-- PWA Meta Tags -->
<meta name="description" content="Community-driven winter road conditions and icy hazards tracker for the Great Lakes region">
<meta name="theme-color" content="#2196F3">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="Ice Report">
<meta name="msapplication-TileColor" content="#2196F3">
<meta name="msapplication-config" content="/browserconfig.xml">
<!-- PWA Manifest -->
<link rel="manifest" href="/manifest.json">
<!-- Icons -->
<link rel="icon" type="image/svg+xml" href="/icons/favicon.svg">
<link rel="icon" type="image/x-icon" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMiAzMiIgd2lkdGg9IjMyIiBoZWlnaHQ9IjMyIj4KICA8ZGVmcz4KICAgIDxzdHlsZT4KICAgICAgLnNub3dmbGFrZSB7IGZpbGw6ICMyMTk2RjM7IH0KICAgICAgLmNlbnRlciB7IGZpbGw6ICMxOTc2RDI7IH0KICAgIDwvc3R5bGU+CiAgPC9kZWZzPgogIAogIDxnIGNsYXNzPSJzbm93Zmxha2UiPgogICAgPHJlY3QgeD0iMTUiIHk9IjIiIHdpZHRoPSIyIiBoZWlnaHQ9IjI4IiAvPgogICAgPHJlY3QgeD0iMiIgeT0iMTUiIHdpZHRoPSIyOCIgaGVpZ2h0PSIyIiAvPgogICAgPHJlY3QgeD0iMTUiIHk9IjIiIHdpZHRoPSIyIiBoZWlnaHQ9IjI4IiB0cmFuc2Zvcm09InJvdGF0ZSg0NSAxNiAxNikiIC8+CiAgICA8cmVjdCB4PSIxNSIgeT0iMiIgd2lkdGg9IjIiIGhlaWdodD0iMjgiIHRyYW5zZm9ybT0icm90YXRlKC00NSAxNiAxNikiIC8+CiAgICA8cG9seWdvbiBwb2ludHM9IjE2LDIgMTQsNiAxOCw2IiAvPgogICAgPHBvbHlnb24gcG9pbnRzPSIxNiwzMCAxNCwyNiAxOCwyNiIgLz4KICAgIDxwb2x5Z29uIHBvaW50cz0iMiwxNiA2LDE0IDYsMTgiIC8+CiAgICA8cG9seWdvbiBwb2ludHM9IjMwLDE2IDI2LDE0IDI2LDE4IiAvPgogICAgPHBvbHlnb24gcG9pbnRzPSI2LjMsNi4zIDguNiw0IDkuOSw3LjciIHRyYW5zZm9ybT0icm90YXRlKDQ1IDE2IDE2KSIgLz4KICAgIDxwb2x5Z29uIHBvaW50cz0iMjUuNywyNS43IDIzLjQsMjggMjIuMSwyNC4zIiB0cmFuc2Zvcm09InJvdGF0ZSg0NSAxNiAxNikiIC8+CiAgICA8cG9seWdvbiBwb2ludHM9IjYuMywyNS43IDguNiwyOCA5LjksMjQuMyIgdHJhbnNmb3JtPSJyb3RhdGUoLTQ1IDE2IDE2KSIgLz4KICAgIDxwb2x5Z29uIHBvaW50cz0iMjUuNyw2LjMgMjMuNCw0IDIyLjEsNy43IiB0cmFuc2Zvcm09InJvdGF0ZSgtNDUgMTYgMTYpIiAvPgogIDwvZz4KICA8Y2lyY2xlIGN4PSIxNiIgY3k9IjE2IiByPSIzIiBjbGFzcz0iY2VudGVyIiAvPgo8L3N2Zz4K">
<!-- Apple Touch Icons -->
<link rel="apple-touch-icon" sizes="152x152" href="/icons/icon-152.svg">
<link rel="apple-touch-icon" sizes="180x180" href="/icons/icon-192.svg">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="style.css">
<script>
@ -154,5 +172,58 @@ placeholder="Enter address, intersection (e.g., Main St & Second St, City), or l
<script src="theme-utils.js"></script>
<script src="utils.js"></script>
<script src="app-mapbox.js"></script>
<!-- PWA Service Worker Registration -->
<script>
// Register service worker for PWA functionality
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then((registration) => {
console.log('✅ Service Worker registered successfully:', registration.scope);
// Check for updates
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
if (newWorker) {
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// New content available, notify user
console.log('🔄 New content available! Please refresh.');
// Could show a notification here
}
}
});
}
});
})
.catch((error) => {
console.warn('❌ Service Worker registration failed:', error);
});
});
} else {
console.log('Service Worker not supported in this browser');
}
// PWA Install Prompt
let deferredPrompt;
window.addEventListener('beforeinstallprompt', (e) => {
console.log('💾 PWA install prompt available');
// Prevent Chrome 67 and earlier from automatically showing the prompt
e.preventDefault();
// Stash the event so it can be triggered later
deferredPrompt = e;
// Could show custom install button here
// For now, let the browser handle it naturally
});
window.addEventListener('appinstalled', (evt) => {
console.log('🎉 PWA was installed successfully!');
deferredPrompt = null;
});
</script>
</body>
</html>

75
public/manifest.json Normal file
View file

@ -0,0 +1,75 @@
{
"name": "Great Lakes Ice Report",
"short_name": "Ice Report",
"description": "Community-driven winter road conditions and icy hazards tracker for the Great Lakes region",
"start_url": "/",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#ffffff",
"theme_color": "#2196F3",
"lang": "en-US",
"scope": "/",
"categories": ["weather", "transportation", "utilities"],
"icons": [
{
"src": "/icons/icon-72.svg",
"sizes": "72x72",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/icons/icon-96.svg",
"sizes": "96x96",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/icons/icon-128.svg",
"sizes": "128x128",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/icons/icon-144.svg",
"sizes": "144x144",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/icons/icon-152.svg",
"sizes": "152x152",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/icons/icon-192.svg",
"sizes": "192x192",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/icons/icon-384.svg",
"sizes": "384x384",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/icons/icon-512.svg",
"sizes": "512x512",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/icons/icon-192-maskable.svg",
"sizes": "192x192",
"type": "image/svg+xml",
"purpose": "maskable"
},
{
"src": "/icons/icon-512-maskable.svg",
"sizes": "512x512",
"type": "image/svg+xml",
"purpose": "maskable"
}
]
}

89
public/offline.html Normal file
View file

@ -0,0 +1,89 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Offline - Great Lakes Ice Report</title>
<link rel="stylesheet" href="style.css">
<style>
.offline-container {
max-width: 600px;
margin: 50px auto;
padding: 2rem;
text-align: center;
}
.offline-icon {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.7;
}
.offline-message {
background: var(--card-bg);
color: var(--text-color);
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.retry-btn {
margin-top: 1rem;
padding: 0.75rem 1.5rem;
background-color: var(--primary-color, #2196F3);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
.retry-btn:hover {
opacity: 0.9;
}
</style>
</head>
<body>
<div class="offline-container">
<div class="offline-message">
<div class="offline-icon">📡</div>
<h1>You're Offline</h1>
<p>It looks like you've lost your internet connection. The Great Lakes Ice Report needs an internet connection to show current road conditions and submit new reports.</p>
<p><strong>When you're back online, you'll be able to:</strong></p>
<ul style="text-align: left; max-width: 400px; margin: 1rem auto;">
<li>View current ice condition reports</li>
<li>Submit new hazard reports</li>
<li>Get real-time location updates</li>
<li>Access the interactive map</li>
</ul>
<button class="retry-btn" onclick="window.location.reload()">
🔄 Try Again
</button>
<p style="margin-top: 1rem; font-size: 0.9rem; opacity: 0.8;">
This app works best with an internet connection for up-to-date safety information.
</p>
</div>
</div>
<script>
// Auto-reload when connection is restored
window.addEventListener('online', () => {
window.location.reload();
});
// Apply theme from localStorage if available
(function() {
const savedTheme = localStorage.getItem('theme') || 'auto';
if (savedTheme === 'auto') {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.setAttribute('data-theme', prefersDark ? 'dark' : 'light');
} else {
document.documentElement.setAttribute('data-theme', savedTheme);
}
})();
</script>
</body>
</html>

View file

@ -4,8 +4,24 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Privacy Policy - Great Lakes Ice Report</title>
<link rel="icon" type="image/svg+xml" href="https://iceymi.b-cdn.net/favicon.svg">
<link rel="icon" type="image/x-icon" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMiAzMiIgd2lkdGg9IjMyIiBoZWlnaHQ9IjMyIj4KICA8ZGVmcz4KICAgIDxzdHlsZT4KICAgICAgLnNub3dmbGFrZSB7IGZpbGw6ICMyMTk2RjM7IH0KICAgICAgLmNlbnRlciB7IGZpbGw6ICMxOTc2RDI7IH0KICAgIDwvc3R5bGU+CiAgPC9kZWZzPgogIAogIDxnIGNsYXNzPSJzbm93Zmxha2UiPgogICAgPHJlY3QgeD0iMTUiIHk9IjIiIHdpZHRoPSIyIiBoZWlnaHQ9IjI4IiAvPgogICAgPHJlY3QgeD0iMiIgeT0iMTUiIHdpZHRoPSIyOCIgaGVpZ2h0PSIyIiAvPgogICAgPHJlY3QgeD0iMTUiIHk9IjIiIHdpZHRoPSIyIiBoZWlnaHQ9IjI4IiB0cmFuc2Zvcm09InJvdGF0ZSg0NSAxNiAxNikiIC8+CiAgICA8cmVjdCB4PSIxNSIgeT0iMiIgd2lkdGg9IjIiIGhlaWdodD0iMjgiIHRyYW5zZm9ybT0icm90YXRlKC00NSAxNiAxNikiIC8+CiAgICA8cG9seWdvbiBwb2ludHM9IjE2LDIgMTQsNiAxOCw2IiAvPgogICAgPHBvbHlnb24gcG9pbnRzPSIxNiwzMCAxNCwyNiAxOCwyNiIgLz4KICAgIDxwb2x5Z29uIHBvaW50cz0iMiwxNiA2LDE0IDYsMTgiIC8+CiAgICA8cG9seWdvbiBwb2ludHM9IjMwLDE2IDI2LDE0IDI2LDE4IiAvPgogICAgPHBvbHlnb24gcG9pbnRzPSI2LjMsNi4zIDguNiw0IDkuOSw3LjciIHRyYW5zZm9ybT0icm90YXRlKDQ1IDE2IDE2KSIgLz4KICAgIDxwb2x5Z29uIHBvaW50cz0iMjUuNywyNS43IDIzLjQsMjggMjIuMSwyNC4zIiB0cmFuc2Zvcm09InJvdGF0ZSg0NSAxNiAxNikiIC8+CiAgICA8cG9seWdvbiBwb2ludHM9IjYuMywyNS43IDguNiwyOCA5LjksMjQuMyIgdHJhbnNmb3JtPSJyb3RhdGUoLTQ1IDE2IDE2KSIgLz4KICAgIDxwb2x5Z29uIHBvaW50cz0iMjUuNyw2LjMgMjMuNCw0IDIyLjEsNy43IiB0cmFuc2Zvcm09InJvdGF0ZSgtNDUgMTYgMTYpIiAvPgogIDwvZz4KICA8Y2lyY2xlIGN4PSIxNiIgY3k9IjE2IiByPSIzIiBjbGFzcz0iY2VudGVyIiAvPgo8L3N2Zz4K">
<!-- PWA Meta Tags -->
<meta name="description" content="Privacy policy for Great Lakes Ice Report - winter road conditions tracker">
<meta name="theme-color" content="#2196F3">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="Ice Report Privacy">
<!-- PWA Manifest -->
<link rel="manifest" href="/manifest.json">
<!-- Icons -->
<link rel="icon" type="image/svg+xml" href="/icons/favicon.svg">
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMiAzMiIgd2lkdGg9IjMyIiBoZWlnaHQ9IjMyIj4KICA8ZGVmcz4KICAgIDxzdHlsZT4KICAgICAgLnNub3dmbGFrZSB7IGZpbGw6ICMyMTk2RjM7IH0KICAgICAgLmNlbnRlciB7IGZpbGw6ICMxOTc2RDI7IH0KICAgIDwvc3R5bGU+CiAgPC9kZWZzPgogIAogIDxnIGNsYXNzPSJzbm93Zmxha2UiPgogICAgPHJlY3QgeD0iMTUiIHk9IjIiIHdpZHRoPSIyIiBoZWlnaHQ9IjI4IiAvPgogICAgPHJlY3QgeD0iMiIgeT0iMTUiIHdpZHRoPSIyOCIgaGVpZ2h0PSIyIiAvPgogICAgPHJlY3QgeD0iMTUiIHk9IjIiIHdpZHRoPSIyIiBoZWlnaHQ9IjI4IiB0cmFuc2Zvcm09InJvdGF0ZSg0NSAxNiAxNikiIC8+CiAgICA8cmVjdCB4PSIxNSIgeT0iMiIgd2lkdGg9IjIiIGhlaWdodD0iMjgiIHRyYW5zZm9ybT0icm90YXRlKC00NSAxNiAxNikiIC8+CiAgICA8cG9seWdvbiBwb2ludHM9IjE2LDIgMTQsNiAxOCw2IiAvPgogICAgPHBvbHlnb24gcG9pbnRzPSIxNiwzMCAxNCwyNiAxOCwyNiIgLz4KICAgIDxwb2x5Z29uIHBvaW50cz0iMiwxNiA2LDE0IDYsMTgiIC8+CiAgICA8cG9seWdvbiBwb2ludHM9IjMwLDE2IDI2LDE0IDI2LDE4IiAvPgogICAgPHBvbHlnb24gcG9pbnRzPSI2LjMsNi4zIDguNiw0IDkuOSw3LjciIHRyYW5zZm9ybT0icm90YXRlKDQ1IDE2IDE2KSIgLz4KICAgIDxwb2x5Z29uIHBvaW50cz0iMjUuNywyNS43IDIzLjQsMjggMjIuMSwyNC4zIiB0cmFuc2Zvcm09InJvdGF0ZSg0NSAxNiAxNikiIC8+CiAgICA8cG9seWdvbiBwb2ludHM9IjYuMywyNS43IDguNiwyOCA5LjksMjQuMyIgdHJhbnNmb3JtPSJyb3RhdGUoLTQ1IDE2IDE2KSIgLz4KICAgIDxwb2x5Z29uIHBvaW50cz0iMjUuNyw2LjMgMjMuNCw0IDIyLjEsNy43IiB0cmFuc2Zvcm09InJvdGF0ZSgtNDUgMTYgMTYpIiAvPgogIDwvZz4KICA8Y2lyY2xlIGN4PSIxNiIgY3k9IjE2IiByPSIzIiBjbGFzcz0iY2VudGVyIiAvPgo8L3N2Zz4K">
<!-- Apple Touch Icons -->
<link rel="apple-touch-icon" sizes="152x152" href="/icons/icon-152.svg">
<link rel="apple-touch-icon" sizes="180x180" href="/icons/icon-192.svg">
<link rel="stylesheet" href="style.css">
<script>
// Apply theme immediately to prevent flash

143
public/sw.js Normal file
View file

@ -0,0 +1,143 @@
// Service Worker for Great Lakes Ice Report PWA
const CACHE_NAME = 'ice-report-v1';
const OFFLINE_URL = '/offline.html';
// Files to cache for offline functionality
const CACHE_FILES = [
'/',
'/index.html',
'/admin.html',
'/privacy.html',
'/style.css',
'/app.js',
'/admin.js',
'/utils.js',
'/manifest.json',
OFFLINE_URL
];
// Install event - cache essential files
self.addEventListener('install', (event) => {
console.log('Service Worker: Installing...');
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => {
console.log('Service Worker: Caching files');
return cache.addAll(CACHE_FILES);
})
.then(() => {
console.log('Service Worker: Files cached successfully');
return self.skipWaiting();
})
.catch((error) => {
console.error('Service Worker: Cache failed:', error);
})
);
});
// Activate event - clean up old caches
self.addEventListener('activate', (event) => {
console.log('Service Worker: Activating...');
event.waitUntil(
caches.keys()
.then((cacheNames) => {
return Promise.all(
cacheNames
.filter((cacheName) => cacheName !== CACHE_NAME)
.map((cacheName) => {
console.log('Service Worker: Deleting old cache:', cacheName);
return caches.delete(cacheName);
})
);
})
.then(() => {
console.log('Service Worker: Activated');
return self.clients.claim();
})
);
});
// Fetch event - serve cached content when offline
self.addEventListener('fetch', (event) => {
// Only handle GET requests
if (event.request.method !== 'GET') {
return;
}
// Skip cross-origin requests
if (!event.request.url.startsWith(self.location.origin)) {
return;
}
event.respondWith(
caches.match(event.request)
.then((response) => {
// Return cached version if available
if (response) {
return response;
}
// Try to fetch from network
return fetch(event.request)
.then((response) => {
// Don't cache non-successful responses
if (!response || response.status !== 200 || response.type !== 'basic') {
return response;
}
// Clone the response for caching
const responseToCache = response.clone();
// Cache successful responses for static assets
if (event.request.url.match(/\.(js|css|html|png|jpg|jpeg|gif|svg|ico|woff|woff2)$/)) {
caches.open(CACHE_NAME)
.then((cache) => {
cache.put(event.request, responseToCache);
});
}
return response;
})
.catch(() => {
// If network fails and we're requesting an HTML page, show offline page
if (event.request.headers.get('accept').includes('text/html')) {
return caches.match(OFFLINE_URL);
}
// For other requests, just fail
throw new Error('Network failed and no cache available');
});
})
);
});
// Background sync for when connection is restored
self.addEventListener('sync', (event) => {
if (event.tag === 'background-sync') {
console.log('Service Worker: Background sync triggered');
// Could implement queued location submissions here
}
});
// Push notifications (for future use)
self.addEventListener('push', (event) => {
if (event.data) {
const data = event.data.json();
console.log('Service Worker: Push message received:', data);
// Could show notifications about severe weather warnings
const options = {
body: data.body,
icon: '/icons/icon-192.png',
badge: '/icons/icon-72.png',
tag: 'ice-report',
renotify: true
};
event.waitUntil(
self.registration.showNotification(data.title, options)
);
}
});

102
scripts/generate-icons.js Normal file
View file

@ -0,0 +1,102 @@
#!/usr/bin/env node
/**
* Simple PWA icon generator for Great Lakes Ice Report
* Creates basic icons using Canvas API for different sizes
*/
const fs = require('fs');
const path = require('path');
// Icon sizes needed for PWA
const SIZES = [72, 96, 128, 144, 152, 192, 384, 512];
const MASKABLE_SIZES = [192, 512];
// Create icons directory if it doesn't exist
const iconsDir = path.join(__dirname, '../public/icons');
if (!fs.existsSync(iconsDir)) {
fs.mkdirSync(iconsDir, { recursive: true });
}
/**
* Generate SVG icon content
*/
function generateSVG(size, isMaskable = false) {
const padding = isMaskable ? size * 0.1 : 0; // 10% padding for maskable icons
const iconSize = size - (padding * 2);
const iconOffset = padding;
return `<?xml version="1.0" encoding="UTF-8"?>
<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#2196F3;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1976D2;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Background circle -->
<circle cx="${size/2}" cy="${size/2}" r="${size/2}" fill="url(#bg)"/>
<!-- Ice crystal/snowflake icon -->
<g transform="translate(${iconOffset + iconSize/2}, ${iconOffset + iconSize/2})">
<!-- Main cross -->
<line x1="${-iconSize*0.3}" y1="0" x2="${iconSize*0.3}" y2="0" stroke="white" stroke-width="${iconSize*0.08}" stroke-linecap="round"/>
<line x1="0" y1="${-iconSize*0.3}" x2="0" y2="${iconSize*0.3}" stroke="white" stroke-width="${iconSize*0.08}" stroke-linecap="round"/>
<!-- Diagonal lines -->
<line x1="${-iconSize*0.21}" y1="${-iconSize*0.21}" x2="${iconSize*0.21}" y2="${iconSize*0.21}" stroke="white" stroke-width="${iconSize*0.06}" stroke-linecap="round"/>
<line x1="${iconSize*0.21}" y1="${-iconSize*0.21}" x2="${-iconSize*0.21}" y2="${iconSize*0.21}" stroke="white" stroke-width="${iconSize*0.06}" stroke-linecap="round"/>
<!-- Small decorative elements -->
<circle cx="0" cy="0" r="${iconSize*0.04}" fill="white"/>
<circle cx="${iconSize*0.15}" cy="0" r="${iconSize*0.02}" fill="white"/>
<circle cx="${-iconSize*0.15}" cy="0" r="${iconSize*0.02}" fill="white"/>
<circle cx="0" cy="${iconSize*0.15}" r="${iconSize*0.02}" fill="white"/>
<circle cx="0" cy="${-iconSize*0.15}" r="${iconSize*0.02}" fill="white"/>
</g>
${isMaskable ? `<!-- Safe zone indicator (invisible) -->
<circle cx="${size/2}" cy="${size/2}" r="${size*0.4}" fill="none" stroke="none"/>` : ''}
</svg>`;
}
// Generate all required icon sizes
console.log('🎨 Generating PWA icons...');
// Generate regular icons
for (const size of SIZES) {
const svgContent = generateSVG(size, false);
// Save as SVG
const svgFilename = `icon-${size}.svg`;
const svgFilepath = path.join(iconsDir, svgFilename);
fs.writeFileSync(svgFilepath, svgContent);
console.log(`✅ Generated ${svgFilename}`);
}
// Generate maskable icons
for (const size of MASKABLE_SIZES) {
const svgContent = generateSVG(size, true);
const svgFilename = `icon-${size}-maskable.svg`;
const svgFilepath = path.join(iconsDir, svgFilename);
fs.writeFileSync(svgFilepath, svgContent);
console.log(`✅ Generated ${svgFilename} (maskable)`);
}
// Create a simple favicon.ico reference
const faviconSVG = generateSVG(32, false);
fs.writeFileSync(path.join(iconsDir, 'favicon.svg'), faviconSVG);
console.log('🎉 Icon generation complete!');
console.log('📁 Icons saved to:', iconsDir);
console.log('');
console.log('📝 Note: SVG icons are generated for development.');
console.log(' For production, convert these to PNG using a tool like:');
console.log(' - sharp (Node.js)');
console.log(' - ImageMagick');
console.log(' - Online converters');
console.log('');
console.log('💡 To use PNG icons, update the file extensions in manifest.json');