mirror of
https://github.com/Tony0410/quietthanks.git
synced 2026-05-24 21:31:41 +08:00
Add PWA infrastructure for iOS notification support
- Add manifest.json for proper PWA installation - Add service worker (sw.js) for push notification handling - Add ServiceWorkerProvider to register SW and manage notifications - Generate PNG icons (192x192, 512x512) for iOS compatibility - Update settings to show notification status and test button - Use service worker showNotification for iOS Safari compatibility Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
BIN
public/icons/icon-192.png
Normal file
BIN
public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
BIN
public/icons/icon-512.png
Normal file
BIN
public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
30
public/manifest.json
Normal file
30
public/manifest.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "Quiet Thanks",
|
||||||
|
"short_name": "Quiet Thanks",
|
||||||
|
"description": "A calm, private gratitude and mood log",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#0a0a0a",
|
||||||
|
"theme_color": "#0a0a0a",
|
||||||
|
"orientation": "portrait-primary",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icons/icon.svg",
|
||||||
|
"sizes": "any",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"categories": ["lifestyle", "health"],
|
||||||
|
"id": "quiet-thanks-pwa"
|
||||||
|
}
|
||||||
94
public/sw.js
Normal file
94
public/sw.js
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
// Service Worker for Quiet Thanks PWA
|
||||||
|
const CACHE_NAME = 'quiet-thanks-v1';
|
||||||
|
|
||||||
|
// Install event - cache essential assets
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
console.log('[SW] Installing service worker...');
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Activate event - clean up old caches
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
console.log('[SW] Activating service worker...');
|
||||||
|
event.waitUntil(clients.claim());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Push event - handle incoming push notifications
|
||||||
|
self.addEventListener('push', (event) => {
|
||||||
|
console.log('[SW] Push event received');
|
||||||
|
|
||||||
|
let data = {
|
||||||
|
title: 'Quiet Thanks',
|
||||||
|
body: 'Take a moment to reflect on what you\'re grateful for today.',
|
||||||
|
icon: '/icons/icon.svg',
|
||||||
|
badge: '/icons/icon.svg',
|
||||||
|
tag: 'daily-reminder',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (event.data) {
|
||||||
|
try {
|
||||||
|
const payload = event.data.json();
|
||||||
|
data = { ...data, ...payload };
|
||||||
|
} catch (e) {
|
||||||
|
data.body = event.data.text();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
body: data.body,
|
||||||
|
icon: data.icon,
|
||||||
|
badge: data.badge,
|
||||||
|
tag: data.tag,
|
||||||
|
vibrate: [100, 50, 100],
|
||||||
|
data: {
|
||||||
|
url: '/',
|
||||||
|
},
|
||||||
|
actions: [
|
||||||
|
{ action: 'open', title: 'Open app' },
|
||||||
|
{ action: 'dismiss', title: 'Dismiss' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification(data.title, options)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notification click handler
|
||||||
|
self.addEventListener('notificationclick', (event) => {
|
||||||
|
console.log('[SW] Notification clicked');
|
||||||
|
event.notification.close();
|
||||||
|
|
||||||
|
if (event.action === 'dismiss') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
clients.matchAll({ type: 'window', includeUncontrolled: true })
|
||||||
|
.then((clientList) => {
|
||||||
|
// Focus existing window if available
|
||||||
|
for (const client of clientList) {
|
||||||
|
if (client.url.includes(self.registration.scope) && 'focus' in client) {
|
||||||
|
return client.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Open new window
|
||||||
|
if (clients.openWindow) {
|
||||||
|
return clients.openWindow('/');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Periodic sync for background reminders (when supported)
|
||||||
|
self.addEventListener('periodicsync', (event) => {
|
||||||
|
if (event.tag === 'daily-reminder') {
|
||||||
|
event.waitUntil(checkAndShowReminder());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function checkAndShowReminder() {
|
||||||
|
// This would check the reminder time and show notification if appropriate
|
||||||
|
// For now, we rely on the client-side scheduler
|
||||||
|
console.log('[SW] Periodic sync triggered');
|
||||||
|
}
|
||||||
26
scripts/generate-icons.mjs
Normal file
26
scripts/generate-icons.mjs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import sharp from "sharp";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import { dirname, join } from "path";
|
||||||
|
import { readFileSync, writeFileSync } from "fs";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
const publicDir = join(__dirname, "..", "public", "icons");
|
||||||
|
|
||||||
|
const svgPath = join(publicDir, "icon.svg");
|
||||||
|
const svgContent = readFileSync(svgPath, "utf-8");
|
||||||
|
|
||||||
|
async function generateIcons() {
|
||||||
|
const sizes = [192, 512];
|
||||||
|
|
||||||
|
for (const size of sizes) {
|
||||||
|
const outputPath = join(publicDir, `icon-${size}.png`);
|
||||||
|
await sharp(Buffer.from(svgContent))
|
||||||
|
.resize(size, size)
|
||||||
|
.png()
|
||||||
|
.toFile(outputPath);
|
||||||
|
console.log(`Generated ${outputPath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
generateIcons().catch(console.error);
|
||||||
@@ -2,13 +2,20 @@ import type { Metadata, Viewport } from "next";
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { APP_NAME } from "@/lib/constants";
|
import { APP_NAME } from "@/lib/constants";
|
||||||
import { AuthProvider } from "@/components/AuthProvider";
|
import { AuthProvider } from "@/components/AuthProvider";
|
||||||
|
import { ServiceWorkerProvider } from "@/components/ServiceWorkerProvider";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: APP_NAME,
|
title: APP_NAME,
|
||||||
description: "A calm, private gratitude and mood log",
|
description: "A calm, private gratitude and mood log",
|
||||||
|
manifest: "/manifest.json",
|
||||||
icons: {
|
icons: {
|
||||||
icon: "/icons/icon.svg",
|
icon: "/icons/icon.svg",
|
||||||
apple: "/icons/icon.svg",
|
apple: "/icons/icon-192.png",
|
||||||
|
},
|
||||||
|
appleWebApp: {
|
||||||
|
capable: true,
|
||||||
|
statusBarStyle: "black-translucent",
|
||||||
|
title: APP_NAME,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -27,12 +34,10 @@ export default function RootLayout({
|
|||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
|
||||||
</head>
|
|
||||||
<body className="antialiased min-h-screen">
|
<body className="antialiased min-h-screen">
|
||||||
<AuthProvider>{children}</AuthProvider>
|
<ServiceWorkerProvider>
|
||||||
|
<AuthProvider>{children}</AuthProvider>
|
||||||
|
</ServiceWorkerProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { AppShell } from "@/components/AppShell";
|
import { AppShell } from "@/components/AppShell";
|
||||||
import { useAuth } from "@/components/AuthProvider";
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
|
import { useServiceWorker } from "@/components/ServiceWorkerProvider";
|
||||||
import { APP_NAME } from "@/lib/constants";
|
import { APP_NAME } from "@/lib/constants";
|
||||||
import { LogOut, Users, Bell, Sparkles, Loader2, Check, Key, ChevronDown } from "lucide-react";
|
import { LogOut, Users, Bell, Sparkles, Loader2, Check, Key, ChevronDown } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -29,11 +30,13 @@ const PROVIDERS = [
|
|||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
|
const { isSupported: swSupported, isRegistered: swRegistered, showNotification } = useServiceWorker();
|
||||||
const [settings, setSettings] = useState<UserSettings | null>(null);
|
const [settings, setSettings] = useState<UserSettings | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [showLlmSetup, setShowLlmSetup] = useState(false);
|
const [showLlmSetup, setShowLlmSetup] = useState(false);
|
||||||
const [notificationPermission, setNotificationPermission] = useState<NotificationPermission>("default");
|
const [notificationPermission, setNotificationPermission] = useState<NotificationPermission>("default");
|
||||||
|
const [isPWA, setIsPWA] = useState(false);
|
||||||
|
|
||||||
// LLM setup state
|
// LLM setup state
|
||||||
const [selectedProvider, setSelectedProvider] = useState<string>("");
|
const [selectedProvider, setSelectedProvider] = useState<string>("");
|
||||||
@@ -68,6 +71,11 @@ export default function SettingsPage() {
|
|||||||
if ("Notification" in window) {
|
if ("Notification" in window) {
|
||||||
setNotificationPermission(Notification.permission);
|
setNotificationPermission(Notification.permission);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detect if running as PWA (standalone mode)
|
||||||
|
const isStandalone = window.matchMedia("(display-mode: standalone)").matches ||
|
||||||
|
(window.navigator as Navigator & { standalone?: boolean }).standalone === true;
|
||||||
|
setIsPWA(isStandalone);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updateSetting = async (key: string, value: unknown) => {
|
const updateSetting = async (key: string, value: unknown) => {
|
||||||
@@ -204,27 +212,35 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Reminder check interval
|
// Reminder check interval - uses service worker for iOS compatibility
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!settings?.reminderEnabled || !settings?.reminderTime) return;
|
if (!settings?.reminderEnabled || !settings?.reminderTime) return;
|
||||||
|
|
||||||
const checkReminder = () => {
|
// Track if we've shown the notification this minute
|
||||||
|
let lastShownMinute = -1;
|
||||||
|
|
||||||
|
const checkReminder = async () => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const [hours, minutes] = settings.reminderTime.split(":").map(Number);
|
const [hours, minutes] = settings.reminderTime.split(":").map(Number);
|
||||||
|
const currentMinute = now.getHours() * 60 + now.getMinutes();
|
||||||
|
|
||||||
if (now.getHours() === hours && now.getMinutes() === minutes) {
|
if (now.getHours() === hours && now.getMinutes() === minutes && currentMinute !== lastShownMinute) {
|
||||||
|
lastShownMinute = currentMinute;
|
||||||
if (Notification.permission === "granted") {
|
if (Notification.permission === "granted") {
|
||||||
new Notification(APP_NAME, {
|
await showNotification(APP_NAME, {
|
||||||
body: "Take a moment to reflect on what you're grateful for today.",
|
body: "Take a moment to reflect on what you're grateful for today.",
|
||||||
icon: "/icon.png",
|
icon: "/icons/icon.svg",
|
||||||
|
tag: "daily-reminder",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Check immediately and then every minute
|
||||||
|
checkReminder();
|
||||||
const interval = setInterval(checkReminder, 60000);
|
const interval = setInterval(checkReminder, 60000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [settings?.reminderEnabled, settings?.reminderTime]);
|
}, [settings?.reminderEnabled, settings?.reminderTime, showNotification]);
|
||||||
|
|
||||||
const getProviderName = (id: string) => PROVIDERS.find(p => p.id === id)?.name || id;
|
const getProviderName = (id: string) => PROVIDERS.find(p => p.id === id)?.name || id;
|
||||||
|
|
||||||
@@ -302,9 +318,37 @@ export default function SettingsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{settings.reminderEnabled && notificationPermission === "granted" && swRegistered && (
|
||||||
|
<div className="mt-3 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-green-500">
|
||||||
|
<Check size={14} />
|
||||||
|
<span>Notifications ready{isPWA ? " (PWA mode)" : ""}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => showNotification(APP_NAME, {
|
||||||
|
body: "Test notification - reminders are working!",
|
||||||
|
icon: "/icons/icon.svg",
|
||||||
|
tag: "test-notification",
|
||||||
|
})}
|
||||||
|
className="text-sm text-accent hover:underline"
|
||||||
|
>
|
||||||
|
Test
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{settings.reminderEnabled && notificationPermission === "granted" && !swRegistered && (
|
||||||
|
<p className="mt-2 text-sm text-amber-400">
|
||||||
|
Service worker loading... Notifications may be limited.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{notificationPermission === "denied" && (
|
{notificationPermission === "denied" && (
|
||||||
<p className="mt-2 text-sm text-red-400">
|
<p className="mt-2 text-sm text-red-400">
|
||||||
Notifications are blocked. Please enable them in your browser settings.
|
Notifications are blocked. Please enable them in your browser/device settings.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{settings.reminderEnabled && notificationPermission === "default" && (
|
||||||
|
<p className="mt-2 text-sm text-amber-400">
|
||||||
|
Notification permission not yet granted. Toggle reminders off and on to request permission.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
99
src/components/ServiceWorkerProvider.tsx
Normal file
99
src/components/ServiceWorkerProvider.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, createContext, useContext, useState, useCallback, ReactNode } from "react";
|
||||||
|
|
||||||
|
interface ServiceWorkerContextValue {
|
||||||
|
isSupported: boolean;
|
||||||
|
isRegistered: boolean;
|
||||||
|
registration: ServiceWorkerRegistration | null;
|
||||||
|
showNotification: (title: string, options?: NotificationOptions) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ServiceWorkerContext = createContext<ServiceWorkerContextValue>({
|
||||||
|
isSupported: false,
|
||||||
|
isRegistered: false,
|
||||||
|
registration: null,
|
||||||
|
showNotification: async () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useServiceWorker() {
|
||||||
|
return useContext(ServiceWorkerContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServiceWorkerProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServiceWorkerProvider({ children }: ServiceWorkerProviderProps) {
|
||||||
|
const [isSupported, setIsSupported] = useState(false);
|
||||||
|
const [isRegistered, setIsRegistered] = useState(false);
|
||||||
|
const [registration, setRegistration] = useState<ServiceWorkerRegistration | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check if service workers are supported
|
||||||
|
if (!("serviceWorker" in navigator)) {
|
||||||
|
console.log("[SW Provider] Service workers not supported");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSupported(true);
|
||||||
|
|
||||||
|
// Register the service worker
|
||||||
|
async function registerServiceWorker() {
|
||||||
|
try {
|
||||||
|
const reg = await navigator.serviceWorker.register("/sw.js", {
|
||||||
|
scope: "/",
|
||||||
|
});
|
||||||
|
console.log("[SW Provider] Service worker registered:", reg.scope);
|
||||||
|
setRegistration(reg);
|
||||||
|
setIsRegistered(true);
|
||||||
|
|
||||||
|
// Check for updates
|
||||||
|
reg.addEventListener("updatefound", () => {
|
||||||
|
console.log("[SW Provider] Service worker update found");
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[SW Provider] Service worker registration failed:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerServiceWorker();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showNotification = useCallback(
|
||||||
|
async (title: string, options?: NotificationOptions) => {
|
||||||
|
if (!registration) {
|
||||||
|
console.log("[SW Provider] No registration, falling back to Notification API");
|
||||||
|
if ("Notification" in window && Notification.permission === "granted") {
|
||||||
|
new Notification(title, options);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await registration.showNotification(title, options);
|
||||||
|
console.log("[SW Provider] Notification shown via service worker");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[SW Provider] Failed to show notification:", error);
|
||||||
|
// Fallback to regular Notification API
|
||||||
|
if ("Notification" in window && Notification.permission === "granted") {
|
||||||
|
new Notification(title, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[registration]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ServiceWorkerContext.Provider
|
||||||
|
value={{
|
||||||
|
isSupported,
|
||||||
|
isRegistered,
|
||||||
|
registration,
|
||||||
|
showNotification,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ServiceWorkerContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user