🚀 Major Enhancement: Complete AI-Powered LifeRPG Platform with Git LFS
✨ New Features: - AI-powered habit creation with natural language processing - HuggingFace transformers integration for sentiment analysis (tracked via Git LFS) - Advanced predictive analytics and behavioral insights - Voice & image input capabilities for hands-free habit tracking - Real-time notifications and community features - Plugin system with extensible architecture 🔧 Technical Improvements: - Comprehensive FastAPI backend with 30+ endpoints - React frontend with PWA capabilities - Advanced authentication with 2FA support - RBAC authorization system - Comprehensive security features (CSRF, rate limiting, audit logging) - Database migrations and health monitoring - Docker containerization support - Git LFS configured for large AI model files (2+ GB) 📚 Documentation & DevOps: - Complete deployment guides for multiple platforms - Professional README with feature highlights - GitHub Actions CI/CD workflows - Comprehensive API documentation - Security audit roadmap and compliance framework - Setup scripts for development environment 🧪 Testing & Quality: - Comprehensive test suite with 20+ test modules - Setup verification scripts - Working development environment with both backend and frontend - Health checks and monitoring systems 🌟 Ready for: - Portfolio showcasing - Community contributions - Production deployment - Professional presentation
This commit is contained in:
@@ -1,135 +1,156 @@
|
||||
{
|
||||
"name": "The Wizard's Grimoire",
|
||||
"short_name": "Grimoire",
|
||||
"description": "Track your magical habits and build powerful routines with The Wizard's Grimoire",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"theme_color": "#7c3aed",
|
||||
"background_color": "#0f172a",
|
||||
"orientation": "portrait-primary",
|
||||
"scope": "/",
|
||||
"categories": [
|
||||
"productivity",
|
||||
"lifestyle",
|
||||
"health"
|
||||
],
|
||||
"lang": "en",
|
||||
"dir": "ltr",
|
||||
"icons": [
|
||||
"name": "LifeRPG - Habit Tracker",
|
||||
"short_name": "LifeRPG",
|
||||
"description": "Gamified habit tracking with RPG mechanics and real-time progress",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"theme_color": "#8b5cf6",
|
||||
"background_color": "#1a1a2e",
|
||||
"orientation": "portrait-primary",
|
||||
"scope": "/",
|
||||
"categories": ["productivity", "lifestyle", "health", "games"],
|
||||
"lang": "en",
|
||||
"dir": "ltr",
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Quick Add Habit",
|
||||
"short_name": "Add Habit",
|
||||
"description": "Quickly add a new habit",
|
||||
"url": "/habits/add",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-128x128.png",
|
||||
"sizes": "128x128",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-152x152.png",
|
||||
"sizes": "152x152",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-384x384.png",
|
||||
"sizes": "384x384",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
"src": "/icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"shortcuts": [
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Today's Habits",
|
||||
"short_name": "Today",
|
||||
"description": "View today's habit list",
|
||||
"url": "/habits/today",
|
||||
"icons": [
|
||||
{
|
||||
"name": "Quick Add Habit",
|
||||
"short_name": "Add Habit",
|
||||
"description": "Quickly add a new magical habit",
|
||||
"url": "/habits/new",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-96x96.png",
|
||||
"sizes": "96x96"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Today's Progress",
|
||||
"short_name": "Today",
|
||||
"description": "View today's habit progress",
|
||||
"url": "/today",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-96x96.png",
|
||||
"sizes": "96x96"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Analytics",
|
||||
"short_name": "Stats",
|
||||
"description": "View your magical progress analytics",
|
||||
"url": "/analytics",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-96x96.png",
|
||||
"sizes": "96x96"
|
||||
}
|
||||
]
|
||||
"src": "/icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"screenshots": [
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Analytics",
|
||||
"short_name": "Stats",
|
||||
"description": "View your progress analytics",
|
||||
"url": "/analytics",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/screenshot-wide.png",
|
||||
"sizes": "1280x720",
|
||||
"type": "image/png",
|
||||
"form_factor": "wide",
|
||||
"label": "The Wizard's Grimoire desktop interface"
|
||||
},
|
||||
{
|
||||
"src": "/screenshot-narrow.png",
|
||||
"sizes": "375x812",
|
||||
"type": "image/png",
|
||||
"form_factor": "narrow",
|
||||
"label": "The Wizard's Grimoire mobile interface"
|
||||
"src": "/icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"prefer_related_applications": false,
|
||||
"related_applications": [
|
||||
{
|
||||
"platform": "webapp",
|
||||
"url": "https://wizards-grimoire.app"
|
||||
}
|
||||
],
|
||||
"protocol_handlers": [
|
||||
{
|
||||
"protocol": "web+grimoire",
|
||||
"url": "/share?habit=%s"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-128x128.png",
|
||||
"sizes": "128x128",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-152x152.png",
|
||||
"sizes": "152x152",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-384x384.png",
|
||||
"sizes": "384x384",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
}
|
||||
],
|
||||
"screenshots": [
|
||||
{
|
||||
"src": "/screenshots/mobile-habits-list.png",
|
||||
"sizes": "390x844",
|
||||
"type": "image/png",
|
||||
"platform": "narrow",
|
||||
"label": "Habit tracking interface on mobile"
|
||||
},
|
||||
{
|
||||
"src": "/screenshots/mobile-analytics.png",
|
||||
"sizes": "390x844",
|
||||
"type": "image/png",
|
||||
"platform": "narrow",
|
||||
"label": "Analytics dashboard on mobile"
|
||||
},
|
||||
{
|
||||
"src": "/screenshots/desktop-dashboard.png",
|
||||
"sizes": "1280x800",
|
||||
"type": "image/png",
|
||||
"platform": "wide",
|
||||
"label": "Full dashboard on desktop"
|
||||
}
|
||||
],
|
||||
"prefer_related_applications": false,
|
||||
"related_applications": [
|
||||
{
|
||||
"platform": "play",
|
||||
"url": "https://play.google.com/store/apps/details?id=com.liferpg.app",
|
||||
"id": "com.liferpg.app"
|
||||
},
|
||||
{
|
||||
"platform": "itunes",
|
||||
"url": "https://apps.apple.com/app/liferpg/id123456789",
|
||||
"id": "123456789"
|
||||
}
|
||||
],
|
||||
"protocol_handlers": [
|
||||
{
|
||||
"protocol": "liferpg",
|
||||
"url": "/habit?id=%s"
|
||||
}
|
||||
],
|
||||
"file_handlers": [
|
||||
{
|
||||
"action": "/import",
|
||||
"accept": {
|
||||
"application/json": [".json"],
|
||||
"text/csv": [".csv"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+236
-233
@@ -1,264 +1,267 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Offline - The Wizard's Grimoire</title>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>LifeRPG - Offline</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: linear-gradient(135deg, #1e1b4b, #312e81, #1e1b4b);
|
||||
color: #e2e8f0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
text-align: center;
|
||||
max-width: 500px;
|
||||
padding: 2rem;
|
||||
background: rgba(30, 27, 75, 0.8);
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(124, 58, 237, 0.3);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: linear-gradient(135deg, #1e1b4b, #312e81, #1e1b4b);
|
||||
color: #e2e8f0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #c084fc;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #cbd5e1;
|
||||
margin-bottom: 2rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-block;
|
||||
background: linear-gradient(135deg, #7c3aed, #a855f7);
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
border-radius: 12px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(124, 58, 237, 0.4);
|
||||
}
|
||||
|
||||
.button.secondary {
|
||||
background: transparent;
|
||||
border: 2px solid #7c3aed;
|
||||
color: #c084fc;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-top: 2rem;
|
||||
padding: 1rem;
|
||||
background: rgba(249, 115, 22, 0.1);
|
||||
border: 1px solid rgba(249, 115, 22, 0.3);
|
||||
border-radius: 12px;
|
||||
color: #fed7aa;
|
||||
}
|
||||
|
||||
.features {
|
||||
margin-top: 2rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.features h3 {
|
||||
color: #c084fc;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.features ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.features li {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid rgba(124, 58, 237, 0.2);
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.features li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.features li::before {
|
||||
content: "✨ ";
|
||||
color: #a855f7;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.network-status {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: 8px 16px;
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
border-radius: 20px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.network-status.online {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.container {
|
||||
text-align: center;
|
||||
max-width: 500px;
|
||||
padding: 2rem;
|
||||
background: rgba(30, 27, 75, 0.8);
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(124, 58, 237, 0.3);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
padding: 1.5rem;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #c084fc;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #cbd5e1;
|
||||
margin-bottom: 2rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-block;
|
||||
background: linear-gradient(135deg, #7c3aed, #a855f7);
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
border-radius: 12px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(124, 58, 237, 0.4);
|
||||
}
|
||||
|
||||
.button.secondary {
|
||||
background: transparent;
|
||||
border: 2px solid #7c3aed;
|
||||
color: #c084fc;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-top: 2rem;
|
||||
padding: 1rem;
|
||||
background: rgba(249, 115, 22, 0.1);
|
||||
border: 1px solid rgba(249, 115, 22, 0.3);
|
||||
border-radius: 12px;
|
||||
color: #fed7aa;
|
||||
}
|
||||
|
||||
.features {
|
||||
margin-top: 2rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.features h3 {
|
||||
color: #c084fc;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.features ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.features li {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid rgba(124, 58, 237, 0.2);
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.features li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.features li::before {
|
||||
content: "✨ ";
|
||||
color: #a855f7;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.network-status {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: 8px 16px;
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
border-radius: 20px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.network-status.online {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.container {
|
||||
padding: 1.5rem;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 3rem;
|
||||
}
|
||||
.icon {
|
||||
font-size: 3rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<body>
|
||||
<div id="networkStatus" class="network-status">📡 Offline</div>
|
||||
|
||||
<div class="container">
|
||||
<div class="icon">🧙♂️</div>
|
||||
<h1>You're Offline</h1>
|
||||
<p>Your magical connection has been temporarily disrupted, but your grimoire is still accessible!</p>
|
||||
<div class="icon">🧙♂️</div>
|
||||
<h1>You're Offline</h1>
|
||||
<p>
|
||||
Your magical connection has been temporarily disrupted, but your
|
||||
grimoire is still accessible!
|
||||
</p>
|
||||
|
||||
<div style="margin-bottom: 2rem;">
|
||||
<button class="button" onclick="tryReconnect()">🔄 Try Reconnecting</button>
|
||||
<a href="/" class="button secondary">📖 Open Grimoire</a>
|
||||
</div>
|
||||
<div style="margin-bottom: 2rem">
|
||||
<button class="button" onclick="tryReconnect()">
|
||||
🔄 Try Reconnecting
|
||||
</button>
|
||||
<a href="/" class="button secondary">📖 Open Grimoire</a>
|
||||
</div>
|
||||
|
||||
<div class="status">
|
||||
<strong>🛡️ Offline Mode Active</strong><br>
|
||||
Your data is safely stored locally and will sync when connection is restored.
|
||||
</div>
|
||||
<div class="status">
|
||||
<strong>🛡️ Offline Mode Active</strong><br />
|
||||
Your data is safely stored locally and will sync when connection is
|
||||
restored.
|
||||
</div>
|
||||
|
||||
<div class="features">
|
||||
<h3>✨ What You Can Still Do</h3>
|
||||
<ul>
|
||||
<li>View your existing habits and progress</li>
|
||||
<li>Mark habits as complete (will sync later)</li>
|
||||
<li>Browse cached analytics and insights</li>
|
||||
<li>Access previously loaded content</li>
|
||||
<li>Create new habits (will sync when online)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="features">
|
||||
<h3>✨ What You Can Still Do</h3>
|
||||
<ul>
|
||||
<li>View your existing habits and progress</li>
|
||||
<li>Mark habits as complete (will sync later)</li>
|
||||
<li>Browse cached analytics and insights</li>
|
||||
<li>Access previously loaded content</li>
|
||||
<li>Create new habits (will sync when online)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Check network status
|
||||
function updateNetworkStatus() {
|
||||
const statusEl = document.getElementById('networkStatus');
|
||||
if (navigator.onLine) {
|
||||
statusEl.textContent = '🌐 Back Online!';
|
||||
statusEl.className = 'network-status online';
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 2000);
|
||||
} else {
|
||||
statusEl.textContent = '📡 Offline';
|
||||
statusEl.className = 'network-status';
|
||||
}
|
||||
// Check network status
|
||||
function updateNetworkStatus() {
|
||||
const statusEl = document.getElementById("networkStatus");
|
||||
if (navigator.onLine) {
|
||||
statusEl.textContent = "🌐 Back Online!";
|
||||
statusEl.className = "network-status online";
|
||||
setTimeout(() => {
|
||||
window.location.href = "/";
|
||||
}, 2000);
|
||||
} else {
|
||||
statusEl.textContent = "📡 Offline";
|
||||
statusEl.className = "network-status";
|
||||
}
|
||||
}
|
||||
|
||||
// Try to reconnect
|
||||
function tryReconnect() {
|
||||
const button = event.target;
|
||||
button.textContent = '🔄 Checking...';
|
||||
button.disabled = true;
|
||||
// Try to reconnect
|
||||
function tryReconnect() {
|
||||
const button = event.target;
|
||||
button.textContent = "🔄 Checking...";
|
||||
button.disabled = true;
|
||||
|
||||
// Simple connectivity test
|
||||
fetch('/', {
|
||||
method: 'HEAD',
|
||||
cache: 'no-cache'
|
||||
// Simple connectivity test
|
||||
fetch("/", {
|
||||
method: "HEAD",
|
||||
cache: "no-cache",
|
||||
})
|
||||
.then(() => {
|
||||
button.textContent = "✅ Connected!";
|
||||
setTimeout(() => {
|
||||
window.location.href = "/";
|
||||
}, 1000);
|
||||
})
|
||||
.catch(() => {
|
||||
button.textContent = "❌ Still Offline";
|
||||
setTimeout(() => {
|
||||
button.textContent = "🔄 Try Reconnecting";
|
||||
button.disabled = false;
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
// Listen for network changes
|
||||
window.addEventListener("online", updateNetworkStatus);
|
||||
window.addEventListener("offline", updateNetworkStatus);
|
||||
|
||||
// Initial status check
|
||||
updateNetworkStatus();
|
||||
|
||||
// Auto-retry connection every 30 seconds
|
||||
setInterval(() => {
|
||||
if (!navigator.onLine) {
|
||||
fetch("/", {
|
||||
method: "HEAD",
|
||||
cache: "no-cache",
|
||||
})
|
||||
.then(() => {
|
||||
updateNetworkStatus();
|
||||
})
|
||||
.then(() => {
|
||||
button.textContent = '✅ Connected!';
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 1000);
|
||||
})
|
||||
.catch(() => {
|
||||
button.textContent = '❌ Still Offline';
|
||||
setTimeout(() => {
|
||||
button.textContent = '🔄 Try Reconnecting';
|
||||
button.disabled = false;
|
||||
}, 2000);
|
||||
});
|
||||
.catch(() => {
|
||||
// Still offline
|
||||
});
|
||||
}
|
||||
|
||||
// Listen for network changes
|
||||
window.addEventListener('online', updateNetworkStatus);
|
||||
window.addEventListener('offline', updateNetworkStatus);
|
||||
|
||||
// Initial status check
|
||||
updateNetworkStatus();
|
||||
|
||||
// Auto-retry connection every 30 seconds
|
||||
setInterval(() => {
|
||||
if (!navigator.onLine) {
|
||||
fetch('/', {
|
||||
method: 'HEAD',
|
||||
cache: 'no-cache'
|
||||
})
|
||||
.then(() => {
|
||||
updateNetworkStatus();
|
||||
})
|
||||
.catch(() => {
|
||||
// Still offline
|
||||
});
|
||||
}
|
||||
}, 30000);
|
||||
}, 30000);
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
const CACHE_NAME = "wizards-grimoire-v1.0.0";
|
||||
const OFFLINE_URL = "/offline.html";
|
||||
const API_CACHE_NAME = "api-cache-v1";
|
||||
const SECURE_CACHE_NAME = "secure-cache-v1";
|
||||
|
||||
// Security configuration
|
||||
const SECURITY_CONFIG = {
|
||||
// Cache security policies
|
||||
cacheSecurityHeaders: true,
|
||||
encryptSensitiveData: true,
|
||||
maxCacheAge: 24 * 60 * 60 * 1000, // 24 hours
|
||||
|
||||
// CSP configuration
|
||||
contentSecurityPolicy:
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https:; font-src 'self'; object-src 'none'; media-src 'self'; frame-src 'none';",
|
||||
|
||||
// Allowed origins for API requests
|
||||
allowedOrigins: [
|
||||
"https://wizardsgrimoire.com",
|
||||
"https://api.wizardsgrimoire.com",
|
||||
"http://localhost:3000", // Development only
|
||||
"http://localhost:8000", // Development only
|
||||
],
|
||||
|
||||
// Sensitive data patterns (never cache these)
|
||||
sensitivePatterns: [
|
||||
/\/api\/.*\/auth/,
|
||||
/\/api\/.*\/login/,
|
||||
/\/api\/.*\/2fa/,
|
||||
/\/api\/.*\/token/,
|
||||
/\/api\/.*\/password/,
|
||||
/\/api\/.*\/gdpr/,
|
||||
],
|
||||
|
||||
// Cacheable patterns with encryption
|
||||
secureCachePatterns: [
|
||||
/\/api\/v1\/habits$/,
|
||||
/\/api\/v1\/user\/profile$/,
|
||||
/\/api\/v1\/analytics/,
|
||||
],
|
||||
};
|
||||
|
||||
// Resources to cache immediately (only non-sensitive)
|
||||
const STATIC_CACHE_URLS = [
|
||||
"/",
|
||||
"/static/js/bundle.js",
|
||||
"/static/css/main.css",
|
||||
"/manifest.json",
|
||||
"/icon-192x192.png",
|
||||
"/icon-512x512.png",
|
||||
OFFLINE_URL,
|
||||
];
|
||||
|
||||
// Simple encryption for cached sensitive data
|
||||
class CacheEncryption {
|
||||
static async encrypt(data, key = "wizards-grimoire-cache-key") {
|
||||
try {
|
||||
const encoder = new TextEncoder();
|
||||
const keyData = encoder.encode(key);
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
keyData,
|
||||
{ name: "AES-GCM" },
|
||||
false,
|
||||
["encrypt"]
|
||||
);
|
||||
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const encodedData = encoder.encode(JSON.stringify(data));
|
||||
|
||||
const encrypted = await crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv: iv },
|
||||
cryptoKey,
|
||||
encodedData
|
||||
);
|
||||
|
||||
return {
|
||||
encrypted: Array.from(new Uint8Array(encrypted)),
|
||||
iv: Array.from(iv),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Cache encryption failed:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static async decrypt(encryptedData, key = "wizards-grimoire-cache-key") {
|
||||
try {
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
const keyData = encoder.encode(key);
|
||||
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
keyData,
|
||||
{ name: "AES-GCM" },
|
||||
false,
|
||||
["decrypt"]
|
||||
);
|
||||
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{
|
||||
name: "AES-GCM",
|
||||
iv: new Uint8Array(encryptedData.iv),
|
||||
},
|
||||
cryptoKey,
|
||||
new Uint8Array(encryptedData.encrypted)
|
||||
);
|
||||
|
||||
return JSON.parse(decoder.decode(decrypted));
|
||||
} catch (error) {
|
||||
console.error("Cache decryption failed:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Security utilities
|
||||
function isSensitiveRequest(url) {
|
||||
return SECURITY_CONFIG.sensitivePatterns.some((pattern) => pattern.test(url));
|
||||
}
|
||||
|
||||
function isAllowedOrigin(origin) {
|
||||
return SECURITY_CONFIG.allowedOrigins.includes(origin);
|
||||
}
|
||||
|
||||
function shouldEncryptCache(url) {
|
||||
return SECURITY_CONFIG.secureCachePatterns.some((pattern) =>
|
||||
pattern.test(url)
|
||||
);
|
||||
}
|
||||
|
||||
function sanitizeResponse(response, url) {
|
||||
// Remove sensitive headers from cached responses
|
||||
const sanitizedHeaders = new Headers();
|
||||
|
||||
// Copy safe headers only
|
||||
const safeHeaders = [
|
||||
"content-type",
|
||||
"cache-control",
|
||||
"expires",
|
||||
"last-modified",
|
||||
"etag",
|
||||
];
|
||||
|
||||
safeHeaders.forEach((header) => {
|
||||
if (response.headers.has(header)) {
|
||||
sanitizedHeaders.set(header, response.headers.get(header));
|
||||
}
|
||||
});
|
||||
|
||||
// Add security headers
|
||||
sanitizedHeaders.set("X-Cached-By", "ServiceWorker");
|
||||
sanitizedHeaders.set("X-Cache-Timestamp", new Date().toISOString());
|
||||
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: sanitizedHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
// Install event - cache static resources securely
|
||||
self.addEventListener("install", (event) => {
|
||||
console.log("Secure Service Worker: Installing...");
|
||||
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
try {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
console.log("Service Worker: Caching static resources");
|
||||
await cache.addAll(STATIC_CACHE_URLS);
|
||||
|
||||
// Force activation of the new service worker
|
||||
await self.skipWaiting();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Service Worker: Failed to cache static resources",
|
||||
error
|
||||
);
|
||||
}
|
||||
})()
|
||||
);
|
||||
});
|
||||
|
||||
// Activate event - clean up old caches
|
||||
self.addEventListener("activate", (event) => {
|
||||
console.log("Secure Service Worker: Activating...");
|
||||
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
try {
|
||||
const cacheNames = await caches.keys();
|
||||
|
||||
// Delete old caches
|
||||
await Promise.all(
|
||||
cacheNames.map(async (cacheName) => {
|
||||
if (
|
||||
cacheName !== CACHE_NAME &&
|
||||
cacheName !== API_CACHE_NAME &&
|
||||
cacheName !== SECURE_CACHE_NAME
|
||||
) {
|
||||
console.log("Service Worker: Deleting old cache", cacheName);
|
||||
await caches.delete(cacheName);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Take control of all clients
|
||||
await self.clients.claim();
|
||||
} catch (error) {
|
||||
console.error("Service Worker: Activation failed", error);
|
||||
}
|
||||
})()
|
||||
);
|
||||
});
|
||||
|
||||
// Fetch event - secure request handling
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const request = event.request;
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Security checks
|
||||
if (!isAllowedOrigin(url.origin) && url.origin !== self.location.origin) {
|
||||
console.warn(
|
||||
"Service Worker: Blocked request to unauthorized origin",
|
||||
url.origin
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Never cache sensitive requests
|
||||
if (isSensitiveRequest(url.pathname)) {
|
||||
console.log(
|
||||
"Service Worker: Bypassing cache for sensitive request",
|
||||
url.pathname
|
||||
);
|
||||
event.respondWith(fetch(request));
|
||||
return;
|
||||
}
|
||||
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
try {
|
||||
// Try cache first for static resources
|
||||
if (request.method === "GET") {
|
||||
const cachedResponse = await caches.match(request);
|
||||
if (cachedResponse) {
|
||||
// Check cache age
|
||||
const cacheTimestamp =
|
||||
cachedResponse.headers.get("X-Cache-Timestamp");
|
||||
if (cacheTimestamp) {
|
||||
const age = Date.now() - new Date(cacheTimestamp).getTime();
|
||||
if (age > SECURITY_CONFIG.maxCacheAge) {
|
||||
console.log(
|
||||
"Service Worker: Cache expired, fetching fresh",
|
||||
url.pathname
|
||||
);
|
||||
// Cache expired, fetch fresh
|
||||
} else {
|
||||
console.log("Service Worker: Serving from cache", url.pathname);
|
||||
return cachedResponse;
|
||||
}
|
||||
} else {
|
||||
return cachedResponse;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch from network
|
||||
const response = await fetch(request);
|
||||
|
||||
// Cache successful GET responses
|
||||
if (request.method === "GET" && response.status === 200) {
|
||||
try {
|
||||
const cache = await caches.open(
|
||||
shouldEncryptCache(url.pathname) ? SECURE_CACHE_NAME : CACHE_NAME
|
||||
);
|
||||
|
||||
// Sanitize response before caching
|
||||
const sanitizedResponse = sanitizeResponse(
|
||||
response.clone(),
|
||||
url.pathname
|
||||
);
|
||||
|
||||
// For secure endpoints, encrypt the data
|
||||
if (
|
||||
shouldEncryptCache(url.pathname) &&
|
||||
SECURITY_CONFIG.encryptSensitiveData
|
||||
) {
|
||||
const responseData = await response.clone().json();
|
||||
const encryptedData = await CacheEncryption.encrypt(responseData);
|
||||
|
||||
if (encryptedData) {
|
||||
const encryptedResponse = new Response(
|
||||
JSON.stringify({
|
||||
encrypted: true,
|
||||
data: encryptedData,
|
||||
}),
|
||||
{
|
||||
headers: sanitizedResponse.headers,
|
||||
}
|
||||
);
|
||||
await cache.put(request, encryptedResponse);
|
||||
}
|
||||
} else {
|
||||
await cache.put(request, sanitizedResponse);
|
||||
}
|
||||
} catch (cacheError) {
|
||||
console.warn(
|
||||
"Service Worker: Failed to cache response",
|
||||
cacheError
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Service Worker: Fetch failed", error);
|
||||
|
||||
// Return offline page for navigation requests
|
||||
if (request.mode === "navigate") {
|
||||
const offlineResponse = await caches.match(OFFLINE_URL);
|
||||
if (offlineResponse) {
|
||||
return offlineResponse;
|
||||
}
|
||||
}
|
||||
|
||||
// Return basic error response
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Network error",
|
||||
message: "Unable to fetch resource",
|
||||
offline: true,
|
||||
}),
|
||||
{
|
||||
status: 503,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Error-Source": "ServiceWorker",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
})()
|
||||
);
|
||||
});
|
||||
|
||||
// Message handling for cache management
|
||||
self.addEventListener("message", (event) => {
|
||||
if (event.data && event.data.type) {
|
||||
switch (event.data.type) {
|
||||
case "CLEAR_CACHE":
|
||||
clearCache();
|
||||
break;
|
||||
case "CLEAR_SENSITIVE_CACHE":
|
||||
clearSensitiveCache();
|
||||
break;
|
||||
case "UPDATE_SECURITY_CONFIG":
|
||||
// In a real implementation, validate and update security config
|
||||
console.log("Service Worker: Security config update requested");
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function clearCache() {
|
||||
try {
|
||||
const cacheNames = await caches.keys();
|
||||
await Promise.all(cacheNames.map((name) => caches.delete(name)));
|
||||
console.log("Service Worker: All caches cleared");
|
||||
} catch (error) {
|
||||
console.error("Service Worker: Failed to clear cache", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function clearSensitiveCache() {
|
||||
try {
|
||||
await caches.delete(SECURE_CACHE_NAME);
|
||||
console.log("Service Worker: Sensitive cache cleared");
|
||||
} catch (error) {
|
||||
console.error("Service Worker: Failed to clear sensitive cache", error);
|
||||
}
|
||||
}
|
||||
+318
-307
@@ -1,407 +1,418 @@
|
||||
const CACHE_NAME = 'wizards-grimoire-v1.0.0';
|
||||
const OFFLINE_URL = '/offline.html';
|
||||
const API_CACHE_NAME = 'api-cache-v1';
|
||||
const CACHE_NAME = "liferpg-v1.0.0";
|
||||
const OFFLINE_URL = "/offline.html";
|
||||
const API_CACHE_NAME = "api-cache-v1";
|
||||
|
||||
// Resources to cache immediately
|
||||
const STATIC_CACHE_URLS = [
|
||||
'/',
|
||||
'/static/js/bundle.js',
|
||||
'/static/css/main.css',
|
||||
'/manifest.json',
|
||||
'/icon-192x192.png',
|
||||
'/icon-512x512.png',
|
||||
OFFLINE_URL
|
||||
"/",
|
||||
"/static/js/bundle.js",
|
||||
"/static/css/main.css",
|
||||
"/manifest.json",
|
||||
"/icon-72x72.png",
|
||||
"/icon-96x96.png",
|
||||
"/icon-128x128.png",
|
||||
"/icon-144x144.png",
|
||||
"/icon-152x152.png",
|
||||
"/icon-192x192.png",
|
||||
"/icon-384x384.png",
|
||||
"/icon-512x512.png",
|
||||
OFFLINE_URL,
|
||||
];
|
||||
|
||||
// API endpoints to cache
|
||||
const API_CACHE_PATTERNS = [
|
||||
/\/api\/v1\/habits$/,
|
||||
/\/api\/v1\/user\/profile$/,
|
||||
/\/api\/v1\/analytics/
|
||||
/\/api\/v1\/habits$/,
|
||||
/\/api\/v1\/habits\/today$/,
|
||||
/\/api\/v1\/user\/profile$/,
|
||||
/\/api\/v1\/analytics/,
|
||||
/\/api\/v1\/gamification/,
|
||||
];
|
||||
|
||||
// Install event - cache static resources
|
||||
self.addEventListener('install', (event) => {
|
||||
console.log('Service Worker: Installing...');
|
||||
self.addEventListener("install", (event) => {
|
||||
console.log("Service Worker: Installing...");
|
||||
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
try {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
console.log('Service Worker: Caching static resources');
|
||||
await cache.addAll(STATIC_CACHE_URLS);
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
try {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
console.log("Service Worker: Caching static resources");
|
||||
await cache.addAll(STATIC_CACHE_URLS);
|
||||
|
||||
// Force activation of the new service worker
|
||||
await self.skipWaiting();
|
||||
} catch (error) {
|
||||
console.error('Service Worker: Failed to cache static resources', error);
|
||||
}
|
||||
})()
|
||||
);
|
||||
// Force activation of the new service worker
|
||||
await self.skipWaiting();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Service Worker: Failed to cache static resources",
|
||||
error
|
||||
);
|
||||
}
|
||||
})()
|
||||
);
|
||||
});
|
||||
|
||||
// Activate event - clean up old caches
|
||||
self.addEventListener('activate', (event) => {
|
||||
console.log('Service Worker: Activating...');
|
||||
self.addEventListener("activate", (event) => {
|
||||
console.log("Service Worker: Activating...");
|
||||
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
try {
|
||||
const cacheNames = await caches.keys();
|
||||
await Promise.all(
|
||||
cacheNames
|
||||
.filter(cacheName =>
|
||||
cacheName !== CACHE_NAME &&
|
||||
cacheName !== API_CACHE_NAME
|
||||
)
|
||||
.map(cacheName => {
|
||||
console.log('Service Worker: Deleting old cache', cacheName);
|
||||
return caches.delete(cacheName);
|
||||
})
|
||||
);
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
try {
|
||||
const cacheNames = await caches.keys();
|
||||
await Promise.all(
|
||||
cacheNames
|
||||
.filter(
|
||||
(cacheName) =>
|
||||
cacheName !== CACHE_NAME && cacheName !== API_CACHE_NAME
|
||||
)
|
||||
.map((cacheName) => {
|
||||
console.log("Service Worker: Deleting old cache", cacheName);
|
||||
return caches.delete(cacheName);
|
||||
})
|
||||
);
|
||||
|
||||
// Take control of all clients
|
||||
await self.clients.claim();
|
||||
} catch (error) {
|
||||
console.error('Service Worker: Failed to activate', error);
|
||||
}
|
||||
})()
|
||||
);
|
||||
// Take control of all clients
|
||||
await self.clients.claim();
|
||||
} catch (error) {
|
||||
console.error("Service Worker: Failed to activate", error);
|
||||
}
|
||||
})()
|
||||
);
|
||||
});
|
||||
|
||||
// Fetch event - serve cached content when offline
|
||||
self.addEventListener('fetch', (event) => {
|
||||
// Skip non-GET requests for modification requests
|
||||
const url = new URL(event.request.url);
|
||||
self.addEventListener("fetch", (event) => {
|
||||
// Skip non-GET requests for modification requests
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
// Skip chrome-extension requests
|
||||
if (event.request.url.startsWith('chrome-extension://')) return;
|
||||
// Skip chrome-extension requests
|
||||
if (event.request.url.startsWith("chrome-extension://")) return;
|
||||
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
try {
|
||||
// Handle API requests
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
return await handleApiRequest(event.request);
|
||||
}
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
try {
|
||||
// Handle API requests
|
||||
if (url.pathname.startsWith("/api/")) {
|
||||
return await handleApiRequest(event.request);
|
||||
}
|
||||
|
||||
// Handle navigation requests
|
||||
if (event.request.mode === 'navigate') {
|
||||
return await handleNavigationRequest(event.request);
|
||||
}
|
||||
// Handle navigation requests
|
||||
if (event.request.mode === "navigate") {
|
||||
return await handleNavigationRequest(event.request);
|
||||
}
|
||||
|
||||
// Handle static resource requests
|
||||
return await handleStaticRequest(event.request);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Service Worker: Fetch error', error);
|
||||
return await handleFallback(event.request);
|
||||
}
|
||||
})()
|
||||
);
|
||||
// Handle static resource requests
|
||||
return await handleStaticRequest(event.request);
|
||||
} catch (error) {
|
||||
console.error("Service Worker: Fetch error", error);
|
||||
return await handleFallback(event.request);
|
||||
}
|
||||
})()
|
||||
);
|
||||
});
|
||||
|
||||
// Handle API requests with cache-first strategy for GET requests
|
||||
async function handleApiRequest(request) {
|
||||
const url = new URL(request.url);
|
||||
const shouldCache = API_CACHE_PATTERNS.some(pattern => pattern.test(url.pathname));
|
||||
const url = new URL(request.url);
|
||||
const shouldCache = API_CACHE_PATTERNS.some((pattern) =>
|
||||
pattern.test(url.pathname)
|
||||
);
|
||||
|
||||
if (shouldCache && request.method === 'GET') {
|
||||
try {
|
||||
// Try cache first for API requests
|
||||
const cachedResponse = await caches.match(request);
|
||||
if (cachedResponse) {
|
||||
// Return cached response and update in background
|
||||
updateApiCache(request);
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
// Fetch from network and cache
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
const cache = await caches.open(API_CACHE_NAME);
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
|
||||
} catch (error) {
|
||||
// Return cached version if network fails
|
||||
const cachedResponse = await caches.match(request);
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// For non-cached API requests, try network first
|
||||
if (shouldCache && request.method === "GET") {
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
// Try cache first for API requests
|
||||
const cachedResponse = await caches.match(request);
|
||||
if (cachedResponse) {
|
||||
// Return cached response and update in background
|
||||
updateApiCache(request);
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
// If it's a POST/PUT/DELETE request that modifies data, store it for sync
|
||||
if (['POST', 'PUT', 'DELETE'].includes(request.method)) {
|
||||
await storeOfflineAction(request);
|
||||
}
|
||||
|
||||
return response;
|
||||
// Fetch from network and cache
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
const cache = await caches.open(API_CACHE_NAME);
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
// Store the action for later sync
|
||||
if (['POST', 'PUT', 'DELETE'].includes(request.method)) {
|
||||
await storeOfflineAction(request);
|
||||
return new Response(JSON.stringify({ success: true, offline: true }), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
// Return cached version if network fails
|
||||
const cachedResponse = await caches.match(request);
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// For non-cached API requests, try network first
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
|
||||
// If it's a POST/PUT/DELETE request that modifies data, store it for sync
|
||||
if (["POST", "PUT", "DELETE"].includes(request.method)) {
|
||||
await storeOfflineAction(request);
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
// Store the action for later sync
|
||||
if (["POST", "PUT", "DELETE"].includes(request.method)) {
|
||||
await storeOfflineAction(request);
|
||||
return new Response(JSON.stringify({ success: true, offline: true }), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle navigation requests
|
||||
async function handleNavigationRequest(request) {
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
return response;
|
||||
} catch (error) {
|
||||
// Return cached version or offline page
|
||||
const cachedResponse = await caches.match(request);
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
const offlineResponse = await caches.match(OFFLINE_URL);
|
||||
return offlineResponse || new Response('Offline', { status: 200 });
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
return response;
|
||||
} catch (error) {
|
||||
// Return cached version or offline page
|
||||
const cachedResponse = await caches.match(request);
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
const offlineResponse = await caches.match(OFFLINE_URL);
|
||||
return offlineResponse || new Response("Offline", { status: 200 });
|
||||
}
|
||||
}
|
||||
|
||||
// Handle static resource requests
|
||||
async function handleStaticRequest(request) {
|
||||
// Try cache first for static resources
|
||||
const cachedResponse = await caches.match(request);
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
// Try cache first for static resources
|
||||
const cachedResponse = await caches.match(request);
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
// If not in cache, fetch from network
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
|
||||
// Cache successful responses
|
||||
if (response.ok) {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
|
||||
// If not in cache, fetch from network
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
|
||||
// Cache successful responses
|
||||
if (response.ok) {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback handler
|
||||
async function handleFallback(request) {
|
||||
if (request.mode === 'navigate') {
|
||||
const offlineResponse = await caches.match(OFFLINE_URL);
|
||||
return offlineResponse || new Response('Offline', { status: 200 });
|
||||
}
|
||||
if (request.mode === "navigate") {
|
||||
const offlineResponse = await caches.match(OFFLINE_URL);
|
||||
return offlineResponse || new Response("Offline", { status: 200 });
|
||||
}
|
||||
|
||||
return new Response('Resource not available offline', { status: 503 });
|
||||
return new Response("Resource not available offline", { status: 503 });
|
||||
}
|
||||
|
||||
// Update API cache in background
|
||||
async function updateApiCache(request) {
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
const cache = await caches.open(API_CACHE_NAME);
|
||||
await cache.put(request, response);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Background update failed:', error);
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
const cache = await caches.open(API_CACHE_NAME);
|
||||
await cache.put(request, response);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Background update failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Store offline actions for later sync
|
||||
async function storeOfflineAction(request) {
|
||||
try {
|
||||
const action = {
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
body: await request.text(),
|
||||
timestamp: Date.now()
|
||||
};
|
||||
try {
|
||||
const action = {
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
body: await request.text(),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
const existingActions = await getStoredActions();
|
||||
existingActions.push(action);
|
||||
const existingActions = await getStoredActions();
|
||||
existingActions.push(action);
|
||||
|
||||
// Store in IndexedDB or localStorage fallback
|
||||
const storage = await getOfflineStorage();
|
||||
await storage.setItem('offline-actions', JSON.stringify(existingActions));
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to store offline action:', error);
|
||||
}
|
||||
// Store in IndexedDB or localStorage fallback
|
||||
const storage = await getOfflineStorage();
|
||||
await storage.setItem("offline-actions", JSON.stringify(existingActions));
|
||||
} catch (error) {
|
||||
console.error("Failed to store offline action:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Get stored offline actions
|
||||
async function getStoredActions() {
|
||||
try {
|
||||
const storage = await getOfflineStorage();
|
||||
const actions = await storage.getItem('offline-actions');
|
||||
return actions ? JSON.parse(actions) : [];
|
||||
} catch (error) {
|
||||
console.error('Failed to get stored actions:', error);
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const storage = await getOfflineStorage();
|
||||
const actions = await storage.getItem("offline-actions");
|
||||
return actions ? JSON.parse(actions) : [];
|
||||
} catch (error) {
|
||||
console.error("Failed to get stored actions:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Simple storage abstraction
|
||||
async function getOfflineStorage() {
|
||||
// Try to use IndexedDB, fallback to cache storage
|
||||
return {
|
||||
async getItem(key) {
|
||||
const cache = await caches.open('offline-storage');
|
||||
const response = await cache.match(`/${key}`);
|
||||
return response ? await response.text() : null;
|
||||
},
|
||||
// Try to use IndexedDB, fallback to cache storage
|
||||
return {
|
||||
async getItem(key) {
|
||||
const cache = await caches.open("offline-storage");
|
||||
const response = await cache.match(`/${key}`);
|
||||
return response ? await response.text() : null;
|
||||
},
|
||||
|
||||
async setItem(key, value) {
|
||||
const cache = await caches.open('offline-storage');
|
||||
await cache.put(`/${key}`, new Response(value));
|
||||
}
|
||||
};
|
||||
async setItem(key, value) {
|
||||
const cache = await caches.open("offline-storage");
|
||||
await cache.put(`/${key}`, new Response(value));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Background sync event
|
||||
self.addEventListener('sync', (event) => {
|
||||
if (event.tag === 'background-sync') {
|
||||
event.waitUntil(syncOfflineActions());
|
||||
}
|
||||
self.addEventListener("sync", (event) => {
|
||||
if (event.tag === "background-sync") {
|
||||
event.waitUntil(syncOfflineActions());
|
||||
}
|
||||
});
|
||||
|
||||
// Sync offline actions when back online
|
||||
async function syncOfflineActions() {
|
||||
try {
|
||||
const actions = await getStoredActions();
|
||||
const successfulSyncs = [];
|
||||
try {
|
||||
const actions = await getStoredActions();
|
||||
const successfulSyncs = [];
|
||||
|
||||
for (const action of actions) {
|
||||
try {
|
||||
const request = new Request(action.url, {
|
||||
method: action.method,
|
||||
headers: action.headers,
|
||||
body: action.body || undefined
|
||||
});
|
||||
for (const action of actions) {
|
||||
try {
|
||||
const request = new Request(action.url, {
|
||||
method: action.method,
|
||||
headers: action.headers,
|
||||
body: action.body || undefined,
|
||||
});
|
||||
|
||||
const response = await fetch(request);
|
||||
const response = await fetch(request);
|
||||
|
||||
if (response.ok) {
|
||||
successfulSyncs.push(action);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to sync action:', error);
|
||||
}
|
||||
if (response.ok) {
|
||||
successfulSyncs.push(action);
|
||||
}
|
||||
|
||||
// Remove successfully synced actions
|
||||
if (successfulSyncs.length > 0) {
|
||||
const remainingActions = actions.filter(
|
||||
action => !successfulSyncs.includes(action)
|
||||
);
|
||||
|
||||
const storage = await getOfflineStorage();
|
||||
await storage.setItem('offline-actions', JSON.stringify(remainingActions));
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Background sync failed:', error);
|
||||
} catch (error) {
|
||||
console.error("Failed to sync action:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove successfully synced actions
|
||||
if (successfulSyncs.length > 0) {
|
||||
const remainingActions = actions.filter(
|
||||
(action) => !successfulSyncs.includes(action)
|
||||
);
|
||||
|
||||
const storage = await getOfflineStorage();
|
||||
await storage.setItem(
|
||||
"offline-actions",
|
||||
JSON.stringify(remainingActions)
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Background sync failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Push notification event
|
||||
self.addEventListener('push', (event) => {
|
||||
if (!event.data) return;
|
||||
self.addEventListener("push", (event) => {
|
||||
if (!event.data) return;
|
||||
|
||||
try {
|
||||
const data = event.data.json();
|
||||
try {
|
||||
const data = event.data.json();
|
||||
|
||||
const options = {
|
||||
body: data.body || 'Time to practice your magical habits!',
|
||||
icon: '/icon-192x192.png',
|
||||
badge: '/icon-72x72.png',
|
||||
image: data.image,
|
||||
vibrate: [200, 100, 200],
|
||||
data: {
|
||||
url: data.url || '/',
|
||||
action: data.action || 'open'
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
action: 'complete',
|
||||
title: '✓ Mark Complete',
|
||||
icon: '/icon-72x72.png'
|
||||
},
|
||||
{
|
||||
action: 'view',
|
||||
title: '👁 View Details',
|
||||
icon: '/icon-72x72.png'
|
||||
}
|
||||
],
|
||||
requireInteraction: true,
|
||||
tag: data.tag || 'habit-reminder'
|
||||
};
|
||||
const options = {
|
||||
body: data.body || "Time to practice your magical habits!",
|
||||
icon: "/icon-192x192.png",
|
||||
badge: "/icon-72x72.png",
|
||||
image: data.image,
|
||||
vibrate: [200, 100, 200],
|
||||
data: {
|
||||
url: data.url || "/",
|
||||
action: data.action || "open",
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
action: "complete",
|
||||
title: "✓ Mark Complete",
|
||||
icon: "/icon-72x72.png",
|
||||
},
|
||||
{
|
||||
action: "view",
|
||||
title: "👁 View Details",
|
||||
icon: "/icon-72x72.png",
|
||||
},
|
||||
],
|
||||
requireInteraction: true,
|
||||
tag: data.tag || "habit-reminder",
|
||||
};
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(
|
||||
data.title || '🧙♂️ Grimoire Reminder',
|
||||
options
|
||||
)
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Push notification error:', error);
|
||||
}
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(
|
||||
data.title || "🧙♂️ Grimoire Reminder",
|
||||
options
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Push notification error:", error);
|
||||
}
|
||||
});
|
||||
|
||||
// Notification click event
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
|
||||
const action = event.action;
|
||||
const data = event.notification.data;
|
||||
const action = event.action;
|
||||
const data = event.notification.data;
|
||||
|
||||
if (action === 'complete') {
|
||||
// Handle habit completion
|
||||
event.waitUntil(handleHabitCompletion(data));
|
||||
} else {
|
||||
// Open the app
|
||||
event.waitUntil(
|
||||
clients.openWindow(data.url || '/')
|
||||
);
|
||||
}
|
||||
if (action === "complete") {
|
||||
// Handle habit completion
|
||||
event.waitUntil(handleHabitCompletion(data));
|
||||
} else {
|
||||
// Open the app
|
||||
event.waitUntil(clients.openWindow(data.url || "/"));
|
||||
}
|
||||
});
|
||||
|
||||
// Handle habit completion from notification
|
||||
async function handleHabitCompletion(data) {
|
||||
try {
|
||||
if (data.habitId) {
|
||||
// Store completion for sync
|
||||
await storeOfflineAction(new Request(`/api/v1/habits/${data.habitId}/complete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ completedAt: new Date().toISOString() })
|
||||
}));
|
||||
try {
|
||||
if (data.habitId) {
|
||||
// Store completion for sync
|
||||
await storeOfflineAction(
|
||||
new Request(`/api/v1/habits/${data.habitId}/complete`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ completedAt: new Date().toISOString() }),
|
||||
})
|
||||
);
|
||||
|
||||
// Show success notification
|
||||
await self.registration.showNotification('✅ Habit Completed!', {
|
||||
body: 'Great job! Your progress has been recorded.',
|
||||
icon: '/icon-192x192.png',
|
||||
tag: 'completion-success'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to complete habit:', error);
|
||||
// Show success notification
|
||||
await self.registration.showNotification("✅ Habit Completed!", {
|
||||
body: "Great job! Your progress has been recorded.",
|
||||
icon: "/icon-192x192.png",
|
||||
tag: "completion-success",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to complete habit:", error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Service Worker: Loaded');
|
||||
console.log("Service Worker: Loaded");
|
||||
|
||||
Reference in New Issue
Block a user