seo improvement and updated notifs
This commit is contained in:
@@ -15,7 +15,14 @@
|
||||
"Bash(find /Users/jeet/Desktop/Jio/grateful-journal/src -type f -name *.ts -o -name *.tsx)",
|
||||
"Bash(ls -la /Users/jeet/Desktop/Jio/grateful-journal/*.config.*)",
|
||||
"mcp__ide__getDiagnostics",
|
||||
"Bash(npx skills:*)"
|
||||
"Bash(npx skills:*)",
|
||||
"Bash(ls /Users/jeet/Desktop/Jio/grateful-journal/.env*)",
|
||||
"Bash(ls /Users/jeet/Desktop/Jio/grateful-journal/backend/.env*)",
|
||||
"Bash(lsof -ti:8000,4173)",
|
||||
"Bash(npx --yes lighthouse --version)",
|
||||
"Bash(curl:*)",
|
||||
"Bash(npx lighthouse:*)",
|
||||
"Bash(echo \"exit:$?\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,3 +8,8 @@ FRONTEND_URL=http://localhost:8000
|
||||
# MONGODB_URI=mongodb://mongo:27017
|
||||
# ENVIRONMENT=production
|
||||
|
||||
# Firebase Admin SDK service account (for sending push notifications)
|
||||
# Firebase Console → Project Settings → Service Accounts → Generate new private key
|
||||
# Paste the entire JSON on a single line (escape double quotes if needed):
|
||||
FIREBASE_SERVICE_ACCOUNT_JSON=
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -8,6 +8,8 @@ class Settings(BaseSettings):
|
||||
api_port: int = 8001
|
||||
environment: str = "development"
|
||||
frontend_url: str = "http://localhost:8000"
|
||||
# Firebase Admin SDK service account JSON (paste the full JSON as a single-line string)
|
||||
firebase_service_account_json: str = ""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
from fastapi import FastAPI, HTTPException, Depends
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from db import MongoDB, get_database
|
||||
from db import MongoDB
|
||||
from config import get_settings
|
||||
from routers import entries, users
|
||||
from routers import notifications
|
||||
from scheduler import start_scheduler
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
settings = get_settings()
|
||||
_scheduler = None
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup
|
||||
MongoDB.connect_db()
|
||||
global _scheduler
|
||||
_scheduler = start_scheduler()
|
||||
yield
|
||||
# Shutdown
|
||||
if _scheduler:
|
||||
_scheduler.shutdown(wait=False)
|
||||
MongoDB.close_db()
|
||||
|
||||
app = FastAPI(
|
||||
@@ -43,6 +50,7 @@ app.add_middleware(
|
||||
# Include routers
|
||||
app.include_router(users.router, prefix="/api/users", tags=["users"])
|
||||
app.include_router(entries.router, prefix="/api/entries", tags=["entries"])
|
||||
app.include_router(notifications.router, prefix="/api/notifications", tags=["notifications"])
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
|
||||
@@ -5,6 +5,9 @@ pydantic>=2.5.0
|
||||
python-dotenv==1.0.0
|
||||
pydantic-settings>=2.1.0
|
||||
python-multipart==0.0.6
|
||||
firebase-admin>=6.5.0
|
||||
apscheduler>=3.10.4
|
||||
pytz>=2024.1
|
||||
|
||||
# Testing
|
||||
pytest>=7.4.0
|
||||
|
||||
78
backend/routers/notifications.py
Normal file
78
backend/routers/notifications.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""Notification routes — FCM token registration and reminder settings."""
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from db import get_database
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from bson import ObjectId
|
||||
from bson.errors import InvalidId
|
||||
from datetime import datetime
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class FcmTokenRequest(BaseModel):
|
||||
userId: str
|
||||
fcmToken: str
|
||||
|
||||
|
||||
class ReminderSettingsRequest(BaseModel):
|
||||
time: Optional[str] = None # "HH:MM" in 24-hour format
|
||||
enabled: bool
|
||||
timezone: Optional[str] = None # IANA timezone, e.g. "Asia/Kolkata"
|
||||
|
||||
|
||||
@router.post("/fcm-token", response_model=dict)
|
||||
async def register_fcm_token(body: FcmTokenRequest):
|
||||
"""
|
||||
Register (or refresh) an FCM device token for a user.
|
||||
Stores unique tokens per user — duplicate tokens are ignored.
|
||||
"""
|
||||
db = get_database()
|
||||
|
||||
try:
|
||||
user_oid = ObjectId(body.userId)
|
||||
except InvalidId:
|
||||
raise HTTPException(status_code=400, detail="Invalid user ID")
|
||||
|
||||
user = db.users.find_one({"_id": user_oid})
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Add token to set (avoid duplicates)
|
||||
db.users.update_one(
|
||||
{"_id": user_oid},
|
||||
{
|
||||
"$addToSet": {"fcmTokens": body.fcmToken},
|
||||
"$set": {"updatedAt": datetime.utcnow()},
|
||||
}
|
||||
)
|
||||
return {"message": "FCM token registered"}
|
||||
|
||||
|
||||
@router.put("/reminder/{user_id}", response_model=dict)
|
||||
async def update_reminder(user_id: str, settings: ReminderSettingsRequest):
|
||||
"""
|
||||
Save or update daily reminder settings for a user.
|
||||
"""
|
||||
db = get_database()
|
||||
|
||||
try:
|
||||
user_oid = ObjectId(user_id)
|
||||
except InvalidId:
|
||||
raise HTTPException(status_code=400, detail="Invalid user ID")
|
||||
|
||||
user = db.users.find_one({"_id": user_oid})
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
reminder_update: dict = {"reminder.enabled": settings.enabled}
|
||||
if settings.time is not None:
|
||||
reminder_update["reminder.time"] = settings.time
|
||||
if settings.timezone is not None:
|
||||
reminder_update["reminder.timezone"] = settings.timezone
|
||||
|
||||
db.users.update_one(
|
||||
{"_id": user_oid},
|
||||
{"$set": {**reminder_update, "updatedAt": datetime.utcnow()}}
|
||||
)
|
||||
return {"message": "Reminder settings updated"}
|
||||
179
backend/scheduler.py
Normal file
179
backend/scheduler.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""
|
||||
Daily reminder scheduler.
|
||||
|
||||
Runs every minute. For each user with an enabled reminder:
|
||||
- Converts current UTC time to the user's local timezone
|
||||
- Checks if the current HH:MM matches their reminder time
|
||||
- Checks if they already got a notification today (avoids duplicates)
|
||||
- Checks if they have already written a journal entry today
|
||||
- If not, sends an FCM push notification to all their registered devices
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytz
|
||||
import firebase_admin
|
||||
from firebase_admin import credentials, messaging
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
|
||||
from config import get_settings
|
||||
from db import get_database
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_firebase_initialized = False
|
||||
|
||||
|
||||
def init_firebase():
|
||||
"""Initialize Firebase Admin SDK once using the service account JSON from env."""
|
||||
global _firebase_initialized
|
||||
if _firebase_initialized:
|
||||
return
|
||||
|
||||
settings = get_settings()
|
||||
if not settings.firebase_service_account_json:
|
||||
log.warning("FIREBASE_SERVICE_ACCOUNT_JSON not set — push notifications disabled")
|
||||
return
|
||||
|
||||
try:
|
||||
sa_dict = json.loads(settings.firebase_service_account_json)
|
||||
cred = credentials.Certificate(sa_dict)
|
||||
firebase_admin.initialize_app(cred)
|
||||
_firebase_initialized = True
|
||||
log.info("Firebase Admin SDK initialized")
|
||||
except Exception as e:
|
||||
log.error(f"Failed to initialize Firebase Admin SDK: {e}")
|
||||
|
||||
|
||||
def send_reminder_notifications():
|
||||
"""Check all users and send reminders where due."""
|
||||
if not _firebase_initialized:
|
||||
return
|
||||
|
||||
db = get_database()
|
||||
now_utc = datetime.utcnow().replace(second=0, microsecond=0)
|
||||
|
||||
# Find all users with reminder enabled and at least one FCM token
|
||||
users = db.users.find({
|
||||
"reminder.enabled": True,
|
||||
"fcmTokens": {"$exists": True, "$not": {"$size": 0}},
|
||||
"reminder.time": {"$exists": True},
|
||||
})
|
||||
|
||||
for user in users:
|
||||
try:
|
||||
_process_user(db, user, now_utc)
|
||||
except Exception as e:
|
||||
log.error(f"Error processing reminder for user {user.get('_id')}: {e}")
|
||||
|
||||
|
||||
def _process_user(db, user: dict, now_utc: datetime):
|
||||
reminder = user.get("reminder", {})
|
||||
reminder_time_str = reminder.get("time") # "HH:MM"
|
||||
timezone_str = reminder.get("timezone", "UTC")
|
||||
fcm_tokens: list = user.get("fcmTokens", [])
|
||||
|
||||
if not reminder_time_str or not fcm_tokens:
|
||||
return
|
||||
|
||||
try:
|
||||
user_tz = pytz.timezone(timezone_str)
|
||||
except pytz.UnknownTimeZoneError:
|
||||
user_tz = pytz.utc
|
||||
|
||||
# Current time in user's timezone
|
||||
now_local = now_utc.replace(tzinfo=pytz.utc).astimezone(user_tz)
|
||||
current_hm = now_local.strftime("%H:%M")
|
||||
|
||||
if current_hm != reminder_time_str:
|
||||
return # Not the right minute
|
||||
|
||||
# Check if already notified today (in user's local date)
|
||||
today_local_str = now_local.strftime("%Y-%m-%d")
|
||||
last_notified = reminder.get("lastNotifiedDate", "")
|
||||
if last_notified == today_local_str:
|
||||
return # Already sent today
|
||||
|
||||
# Check if user has already written today (using createdAt in their timezone)
|
||||
today_start_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
today_start_utc = today_start_local.astimezone(pytz.utc).replace(tzinfo=None)
|
||||
today_end_utc = today_start_utc + timedelta(days=1)
|
||||
|
||||
entry_count = db.entries.count_documents({
|
||||
"userId": user["_id"],
|
||||
"createdAt": {"$gte": today_start_utc, "$lt": today_end_utc},
|
||||
})
|
||||
|
||||
if entry_count > 0:
|
||||
# Already wrote today — mark notified to avoid repeated checks
|
||||
db.users.update_one(
|
||||
{"_id": user["_id"]},
|
||||
{"$set": {"reminder.lastNotifiedDate": today_local_str}}
|
||||
)
|
||||
return
|
||||
|
||||
# Send FCM notification
|
||||
_send_push(user["_id"], fcm_tokens, db, today_local_str)
|
||||
|
||||
|
||||
def _send_push(user_id, tokens: list, db, today_local_str: str):
|
||||
"""Send FCM multicast and prune stale tokens."""
|
||||
message = messaging.MulticastMessage(
|
||||
notification=messaging.Notification(
|
||||
title="Time to journal 🌱",
|
||||
body="You haven't written today yet. Take a moment to reflect.",
|
||||
),
|
||||
tokens=tokens,
|
||||
android=messaging.AndroidConfig(priority="high"),
|
||||
apns=messaging.APNSConfig(
|
||||
payload=messaging.APNSPayload(
|
||||
aps=messaging.Aps(sound="default")
|
||||
)
|
||||
),
|
||||
webpush=messaging.WebpushConfig(
|
||||
notification=messaging.WebpushNotification(
|
||||
icon="/web-app-manifest-192x192.png",
|
||||
badge="/favicon-96x96.png",
|
||||
tag="gj-daily-reminder",
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
response = messaging.send_each_for_multicast(message)
|
||||
log.info(f"Reminder sent to user {user_id}: {response.success_count} ok, {response.failure_count} failed")
|
||||
|
||||
# Remove tokens that are no longer valid
|
||||
stale_tokens = [
|
||||
tokens[i] for i, r in enumerate(response.responses)
|
||||
if not r.success and r.exception and "not-registered" in str(r.exception).lower()
|
||||
]
|
||||
if stale_tokens:
|
||||
db.users.update_one(
|
||||
{"_id": user_id},
|
||||
{"$pullAll": {"fcmTokens": stale_tokens}}
|
||||
)
|
||||
log.info(f"Removed {len(stale_tokens)} stale FCM tokens for user {user_id}")
|
||||
|
||||
# Mark today as notified
|
||||
db.users.update_one(
|
||||
{"_id": user_id},
|
||||
{"$set": {"reminder.lastNotifiedDate": today_local_str}}
|
||||
)
|
||||
|
||||
|
||||
def start_scheduler() -> BackgroundScheduler:
|
||||
"""Initialize Firebase and start the minute-by-minute scheduler."""
|
||||
init_firebase()
|
||||
|
||||
scheduler = BackgroundScheduler(timezone="UTC")
|
||||
scheduler.add_job(
|
||||
send_reminder_notifications,
|
||||
trigger="cron",
|
||||
minute="*", # every minute
|
||||
id="daily_reminders",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.start()
|
||||
log.info("Reminder scheduler started")
|
||||
return scheduler
|
||||
133
index.html
133
index.html
@@ -30,52 +30,111 @@
|
||||
<meta property="og:title" content="Grateful Journal — Your Private Gratitude Journal" />
|
||||
<meta property="og:description" content="A private, end-to-end encrypted gratitude journal. No feeds, no noise — just you and your thoughts." />
|
||||
<meta property="og:image" content="https://gratefuljournal.online/web-app-manifest-512x512.png" />
|
||||
<meta property="og:image:width" content="512" />
|
||||
<meta property="og:image:height" content="512" />
|
||||
<meta property="og:image:alt" content="Grateful Journal logo — a green sprout" />
|
||||
<meta property="og:site_name" content="Grateful Journal" />
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Grateful Journal — Your Private Gratitude Journal" />
|
||||
<meta name="twitter:description" content="A private, end-to-end encrypted gratitude journal. No feeds, no noise — just you and your thoughts." />
|
||||
<meta name="twitter:image" content="https://gratefuljournal.online/web-app-manifest-512x512.png" />
|
||||
<meta name="twitter:image:alt" content="Grateful Journal logo — a green sprout" />
|
||||
|
||||
<!-- JSON-LD Structured Data -->
|
||||
<!-- JSON-LD: WebSite -->
|
||||
<script type="application/ld+json">
|
||||
[
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"name": "Grateful Journal",
|
||||
"url": "https://gratefuljournal.online/"
|
||||
},
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
"name": "Grateful Journal",
|
||||
"url": "https://gratefuljournal.online/",
|
||||
"logo": "https://gratefuljournal.online/web-app-manifest-512x512.png",
|
||||
"description": "A private, end-to-end encrypted gratitude journal. No feeds, no noise — just you and your thoughts."
|
||||
},
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebApplication",
|
||||
"name": "Grateful Journal",
|
||||
"url": "https://gratefuljournal.online/",
|
||||
"description": "A private, end-to-end encrypted gratitude journal. No feeds, no noise — just you and your thoughts.",
|
||||
"applicationCategory": "LifestyleApplication",
|
||||
"operatingSystem": "Web, Android, iOS",
|
||||
"browserRequirements": "Requires JavaScript. Requires HTML5.",
|
||||
"offers": {
|
||||
"@type": "Offer",
|
||||
"price": "0",
|
||||
"priceCurrency": "USD"
|
||||
},
|
||||
"featureList": [
|
||||
"End-to-end encrypted journal entries",
|
||||
"Daily gratitude prompts",
|
||||
"Private and secure — no ads, no tracking",
|
||||
"Works offline as a PWA"
|
||||
]
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"name": "Grateful Journal",
|
||||
"url": "https://gratefuljournal.online/",
|
||||
"potentialAction": {
|
||||
"@type": "SearchAction",
|
||||
"target": "https://gratefuljournal.online/?q={search_term_string}",
|
||||
"query-input": "required name=search_term_string"
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- JSON-LD: Organization -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
"name": "Grateful Journal",
|
||||
"url": "https://gratefuljournal.online/",
|
||||
"logo": {
|
||||
"@type": "ImageObject",
|
||||
"url": "https://gratefuljournal.online/web-app-manifest-512x512.png",
|
||||
"width": 512,
|
||||
"height": 512
|
||||
},
|
||||
"description": "A private, end-to-end encrypted gratitude journal. No feeds, no noise — just you and your thoughts.",
|
||||
"sameAs": []
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- JSON-LD: WebApplication -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebApplication",
|
||||
"name": "Grateful Journal",
|
||||
"url": "https://gratefuljournal.online/",
|
||||
"description": "A private, end-to-end encrypted gratitude journal. No feeds, no noise — just you and your thoughts.",
|
||||
"applicationCategory": "LifestyleApplication",
|
||||
"operatingSystem": "Web, Android, iOS",
|
||||
"browserRequirements": "Requires JavaScript. Requires HTML5.",
|
||||
"offers": {
|
||||
"@type": "Offer",
|
||||
"price": "0",
|
||||
"priceCurrency": "USD"
|
||||
},
|
||||
"featureList": "End-to-end encrypted journal entries, Daily gratitude prompts, Private and secure — no ads no tracking, Works offline as a PWA"
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- JSON-LD: FAQ -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "FAQPage",
|
||||
"mainEntity": [
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Is Grateful Journal free?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "Yes, Grateful Journal is completely free to use. There is no subscription or paywall."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Are my journal entries private?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "Yes. Your entries are end-to-end encrypted before leaving your device. Even we cannot read them."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Does Grateful Journal work offline?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "Yes. Grateful Journal is a Progressive Web App (PWA) and can be installed on Android, iOS, and desktop. It works offline once installed."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Do you sell my data or show ads?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "No. We do not sell your data, show ads, or use tracking pixels. Your privacy is the foundation of what we built."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,3 +1,24 @@
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_min_length 256;
|
||||
gzip_types
|
||||
text/plain
|
||||
text/css
|
||||
text/xml
|
||||
text/javascript
|
||||
application/javascript
|
||||
application/x-javascript
|
||||
application/json
|
||||
application/xml
|
||||
application/rss+xml
|
||||
application/atom+xml
|
||||
image/svg+xml
|
||||
font/truetype
|
||||
font/opentype
|
||||
application/vnd.ms-fontobject;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
@@ -5,6 +26,20 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Cache hashed static assets (JS/CSS/fonts) for 1 year — Vite adds content hashes
|
||||
location ~* \.(js|css|woff|woff2|ttf|eot|otf)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# Cache images for 30 days
|
||||
location ~* \.(png|jpg|jpeg|gif|ico|svg|webp|avif)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, max-age=2592000";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8001/api/;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
40
public/firebase-messaging-sw.js
Normal file
40
public/firebase-messaging-sw.js
Normal file
@@ -0,0 +1,40 @@
|
||||
// Firebase Cloud Messaging service worker
|
||||
// Config values are injected at build time by the Vite plugin (see vite.config.ts)
|
||||
importScripts('https://www.gstatic.com/firebasejs/10.12.0/firebase-app-compat.js')
|
||||
importScripts('https://www.gstatic.com/firebasejs/10.12.0/firebase-messaging-compat.js')
|
||||
|
||||
firebase.initializeApp({
|
||||
apiKey: '__VITE_FIREBASE_API_KEY__',
|
||||
authDomain: '__VITE_FIREBASE_AUTH_DOMAIN__',
|
||||
projectId: '__VITE_FIREBASE_PROJECT_ID__',
|
||||
messagingSenderId: '__VITE_FIREBASE_MESSAGING_SENDER_ID__',
|
||||
appId: '__VITE_FIREBASE_APP_ID__',
|
||||
})
|
||||
|
||||
const messaging = firebase.messaging()
|
||||
|
||||
// Handle background push messages (browser/PWA is closed or in background)
|
||||
messaging.onBackgroundMessage((payload) => {
|
||||
const title = payload.notification?.title || 'Grateful Journal 🌱'
|
||||
const body = payload.notification?.body || "You haven't written today yet. Take a moment to reflect."
|
||||
|
||||
self.registration.showNotification(title, {
|
||||
body,
|
||||
icon: '/web-app-manifest-192x192.png',
|
||||
badge: '/favicon-96x96.png',
|
||||
tag: 'gj-daily-reminder',
|
||||
})
|
||||
})
|
||||
|
||||
self.addEventListener('notificationclick', (e) => {
|
||||
e.notification.close()
|
||||
e.waitUntil(
|
||||
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
|
||||
if (clients.length > 0) {
|
||||
clients[0].focus()
|
||||
return clients[0].navigate('/')
|
||||
}
|
||||
return self.clients.openWindow('/')
|
||||
})
|
||||
)
|
||||
})
|
||||
@@ -1,6 +1,4 @@
|
||||
User-agent: *
|
||||
Disallow: /write
|
||||
Disallow: /history
|
||||
Disallow: /settings
|
||||
Disallow:
|
||||
|
||||
Sitemap: https://gratefuljournal.online/sitemap.xml
|
||||
|
||||
@@ -2,19 +2,19 @@
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://gratefuljournal.online/</loc>
|
||||
<lastmod>2026-04-08</lastmod>
|
||||
<lastmod>2026-04-13</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://gratefuljournal.online/about</loc>
|
||||
<lastmod>2026-04-08</lastmod>
|
||||
<lastmod>2026-04-13</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://gratefuljournal.online/privacy</loc>
|
||||
<lastmod>2026-04-08</lastmod>
|
||||
<lastmod>2026-04-13</lastmod>
|
||||
<changefreq>yearly</changefreq>
|
||||
<priority>0.4</priority>
|
||||
</url>
|
||||
|
||||
15
public/sw.js
15
public/sw.js
@@ -18,6 +18,21 @@ self.addEventListener('activate', (e) => {
|
||||
self.clients.claim()
|
||||
})
|
||||
|
||||
self.addEventListener('notificationclick', (e) => {
|
||||
e.notification.close()
|
||||
e.waitUntil(
|
||||
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
|
||||
if (clients.length > 0) {
|
||||
const client = clients[0]
|
||||
client.focus()
|
||||
client.navigate('/')
|
||||
} else {
|
||||
self.clients.openWindow('/')
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
self.addEventListener('fetch', (e) => {
|
||||
// Only cache GET requests for same-origin non-API resources
|
||||
if (
|
||||
|
||||
16
src/App.tsx
16
src/App.tsx
@@ -1,18 +1,21 @@
|
||||
import { lazy, Suspense } from 'react'
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { AuthProvider } from './contexts/AuthContext'
|
||||
import { ProtectedRoute } from './components/ProtectedRoute'
|
||||
import HomePage from './pages/HomePage'
|
||||
import HistoryPage from './pages/HistoryPage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import PrivacyPage from './pages/PrivacyPage'
|
||||
import AboutPage from './pages/AboutPage'
|
||||
import './App.css'
|
||||
|
||||
const HomePage = lazy(() => import('./pages/HomePage'))
|
||||
const HistoryPage = lazy(() => import('./pages/HistoryPage'))
|
||||
const SettingsPage = lazy(() => import('./pages/SettingsPage'))
|
||||
const LoginPage = lazy(() => import('./pages/LoginPage'))
|
||||
const PrivacyPage = lazy(() => import('./pages/PrivacyPage'))
|
||||
const AboutPage = lazy(() => import('./pages/AboutPage'))
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<Suspense fallback={null}>
|
||||
<Routes>
|
||||
<Route path="/" element={<LoginPage />} />
|
||||
<Route
|
||||
@@ -43,6 +46,7 @@ function App() {
|
||||
<Route path="/about" element={<AboutPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
)
|
||||
|
||||
43
src/hooks/reminderApi.ts
Normal file
43
src/hooks/reminderApi.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/** API calls specific to FCM token registration and reminder settings. */
|
||||
|
||||
const BASE = import.meta.env.VITE_API_URL || 'http://localhost:8001/api'
|
||||
|
||||
async function post(url: string, body: unknown, token: string) {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}))
|
||||
throw new Error(err.detail || res.statusText)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
async function put(url: string, body: unknown, token: string) {
|
||||
const res = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}))
|
||||
throw new Error(err.detail || res.statusText)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export function saveFcmToken(userId: string, fcmToken: string, authToken: string) {
|
||||
return post(`${BASE}/notifications/fcm-token`, { userId, fcmToken }, authToken)
|
||||
}
|
||||
|
||||
export function saveReminderSettings(
|
||||
userId: string,
|
||||
settings: { time?: string; enabled: boolean; timezone?: string },
|
||||
authToken: string
|
||||
) {
|
||||
return put(`${BASE}/notifications/reminder/${userId}`, settings, authToken)
|
||||
}
|
||||
43
src/hooks/usePageMeta.ts
Normal file
43
src/hooks/usePageMeta.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
interface PageMeta {
|
||||
title: string
|
||||
description: string
|
||||
canonical: string
|
||||
ogTitle?: string
|
||||
ogDescription?: string
|
||||
}
|
||||
|
||||
export function usePageMeta({ title, description, canonical, ogTitle, ogDescription }: PageMeta) {
|
||||
useEffect(() => {
|
||||
document.title = title
|
||||
|
||||
setMeta('name', 'description', description)
|
||||
setMeta('property', 'og:title', ogTitle ?? title)
|
||||
setMeta('property', 'og:description', ogDescription ?? description)
|
||||
setMeta('property', 'og:url', canonical)
|
||||
setMeta('name', 'twitter:title', ogTitle ?? title)
|
||||
setMeta('name', 'twitter:description', ogDescription ?? description)
|
||||
setLink('canonical', canonical)
|
||||
}, [title, description, canonical, ogTitle, ogDescription])
|
||||
}
|
||||
|
||||
function setMeta(attr: 'name' | 'property', key: string, value: string) {
|
||||
let el = document.querySelector<HTMLMetaElement>(`meta[${attr}="${key}"]`)
|
||||
if (!el) {
|
||||
el = document.createElement('meta')
|
||||
el.setAttribute(attr, key)
|
||||
document.head.appendChild(el)
|
||||
}
|
||||
el.setAttribute('content', value)
|
||||
}
|
||||
|
||||
function setLink(rel: string, href: string) {
|
||||
let el = document.querySelector<HTMLLinkElement>(`link[rel="${rel}"]`)
|
||||
if (!el) {
|
||||
el = document.createElement('link')
|
||||
el.setAttribute('rel', rel)
|
||||
document.head.appendChild(el)
|
||||
}
|
||||
el.setAttribute('href', href)
|
||||
}
|
||||
115
src/hooks/useReminder.ts
Normal file
115
src/hooks/useReminder.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Daily reminder — uses Firebase Cloud Messaging (FCM) for true push notifications.
|
||||
* Works even when the browser is fully closed (on mobile PWA).
|
||||
*
|
||||
* Flow:
|
||||
* 1. User picks a time in Settings → enableReminder() is called
|
||||
* 2. Browser notification permission is requested
|
||||
* 3. FCM token is fetched via the firebase-messaging-sw.js service worker
|
||||
* 4. Token + reminder settings are saved to the backend
|
||||
* 5. Backend scheduler sends a push at the right time each day
|
||||
*/
|
||||
import { getToken, onMessage } from 'firebase/messaging'
|
||||
import { messagingPromise } from '../lib/firebase'
|
||||
import { saveFcmToken, saveReminderSettings } from './reminderApi'
|
||||
|
||||
const VAPID_KEY = import.meta.env.VITE_FIREBASE_VAPID_KEY
|
||||
|
||||
export const REMINDER_TIME_KEY = 'gj-reminder-time'
|
||||
export const REMINDER_ENABLED_KEY = 'gj-reminder-enabled'
|
||||
|
||||
export function getSavedReminderTime(): string | null {
|
||||
return localStorage.getItem(REMINDER_TIME_KEY)
|
||||
}
|
||||
|
||||
export function isReminderEnabled(): boolean {
|
||||
return localStorage.getItem(REMINDER_ENABLED_KEY) === 'true'
|
||||
}
|
||||
|
||||
/** Get FCM token using the dedicated firebase-messaging SW. */
|
||||
async function getFcmToken(): Promise<string | null> {
|
||||
const messaging = await messagingPromise
|
||||
if (!messaging) return null
|
||||
|
||||
const swReg = await navigator.serviceWorker.register('/firebase-messaging-sw.js', { scope: '/' })
|
||||
await navigator.serviceWorker.ready
|
||||
|
||||
return getToken(messaging, { vapidKey: VAPID_KEY, serviceWorkerRegistration: swReg })
|
||||
}
|
||||
|
||||
/**
|
||||
* Request permission, get FCM token, and save reminder settings to backend.
|
||||
* Returns an error string on failure, or null on success.
|
||||
*/
|
||||
export async function enableReminder(
|
||||
timeStr: string,
|
||||
userId: string,
|
||||
authToken: string
|
||||
): Promise<string | null> {
|
||||
if (!('Notification' in window)) {
|
||||
return 'Notifications are not supported in this browser.'
|
||||
}
|
||||
|
||||
let perm = Notification.permission
|
||||
if (perm === 'default') {
|
||||
perm = await Notification.requestPermission()
|
||||
}
|
||||
if (perm !== 'granted') {
|
||||
return 'Permission denied. To enable reminders, allow notifications for this site in your browser settings.'
|
||||
}
|
||||
|
||||
try {
|
||||
const fcmToken = await getFcmToken()
|
||||
if (!fcmToken) {
|
||||
return 'Push notifications are not supported in this browser. Try Chrome or Edge.'
|
||||
}
|
||||
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
|
||||
await saveFcmToken(userId, fcmToken, authToken)
|
||||
await saveReminderSettings(userId, { time: timeStr, enabled: true, timezone }, authToken)
|
||||
|
||||
localStorage.setItem(REMINDER_TIME_KEY, timeStr)
|
||||
localStorage.setItem(REMINDER_ENABLED_KEY, 'true')
|
||||
return null
|
||||
} catch (err) {
|
||||
console.error('FCM reminder setup failed', err)
|
||||
return 'Failed to set up push notification. Please try again.'
|
||||
}
|
||||
}
|
||||
|
||||
/** Pause the reminder (keeps the saved time). */
|
||||
export async function disableReminder(userId: string, authToken: string): Promise<void> {
|
||||
await saveReminderSettings(userId, { enabled: false }, authToken)
|
||||
localStorage.setItem(REMINDER_ENABLED_KEY, 'false')
|
||||
}
|
||||
|
||||
/** Re-enable using the previously saved time. Returns error string or null. */
|
||||
export async function reenableReminder(userId: string, authToken: string): Promise<string | null> {
|
||||
const time = localStorage.getItem(REMINDER_TIME_KEY)
|
||||
if (!time) return 'No reminder time saved.'
|
||||
return enableReminder(time, userId, authToken)
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for foreground FCM messages and show a manual notification.
|
||||
* Call once after the app mounts. Returns an unsubscribe function.
|
||||
*/
|
||||
export async function listenForegroundMessages(): Promise<() => void> {
|
||||
const messaging = await messagingPromise
|
||||
if (!messaging) return () => {}
|
||||
|
||||
const unsubscribe = onMessage(messaging, (payload) => {
|
||||
const title = payload.notification?.title || 'Grateful Journal 🌱'
|
||||
const body = payload.notification?.body || "You haven't written today yet."
|
||||
if (Notification.permission === 'granted') {
|
||||
new Notification(title, {
|
||||
body,
|
||||
icon: '/web-app-manifest-192x192.png',
|
||||
tag: 'gj-daily-reminder',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return unsubscribe
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { initializeApp } from 'firebase/app'
|
||||
import { getAuth, GoogleAuthProvider } from 'firebase/auth'
|
||||
import { getMessaging, isSupported } from 'firebase/messaging'
|
||||
|
||||
const firebaseConfig = {
|
||||
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
|
||||
@@ -15,3 +16,6 @@ const app = initializeApp(firebaseConfig)
|
||||
// Google Auth initialization
|
||||
export const auth = getAuth(app)
|
||||
export const googleProvider = new GoogleAuthProvider()
|
||||
|
||||
// FCM Messaging — resolves to null in unsupported browsers (e.g. Firefox, older Safari)
|
||||
export const messagingPromise = isSupported().then((yes) => (yes ? getMessaging(app) : null))
|
||||
|
||||
@@ -2,6 +2,7 @@ import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { listenForegroundMessages } from './hooks/useReminder'
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
@@ -9,6 +10,9 @@ if ('serviceWorker' in navigator) {
|
||||
})
|
||||
}
|
||||
|
||||
// Show FCM notifications when app is open in foreground
|
||||
listenForegroundMessages()
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { usePageMeta } from '../hooks/usePageMeta'
|
||||
|
||||
export default function AboutPage() {
|
||||
usePageMeta({
|
||||
title: 'About — Grateful Journal',
|
||||
description: 'Learn about Grateful Journal — a free, end-to-end encrypted daily gratitude journal. No ads, no tracking, no social feed. Just you and your thoughts.',
|
||||
canonical: 'https://gratefuljournal.online/about',
|
||||
ogTitle: 'About Grateful Journal',
|
||||
ogDescription: 'A free, private gratitude journal with end-to-end encryption. Learn how we built a distraction-free space for your daily reflection practice.',
|
||||
})
|
||||
return (
|
||||
<div className="static-page">
|
||||
<header className="static-page__header">
|
||||
|
||||
@@ -4,8 +4,14 @@ import { useEffect, useState } from 'react'
|
||||
import { GoogleSignInButton } from '../components/GoogleSignInButton'
|
||||
import { TreeAnimation } from '../components/TreeAnimation'
|
||||
import { PageLoader } from '../components/PageLoader'
|
||||
import { usePageMeta } from '../hooks/usePageMeta'
|
||||
|
||||
export default function LoginPage() {
|
||||
usePageMeta({
|
||||
title: 'Grateful Journal — Your Private Gratitude Journal',
|
||||
description: 'A private, end-to-end encrypted gratitude journal. No feeds, no noise — just you and your thoughts. Grow your gratitude one moment at a time.',
|
||||
canonical: 'https://gratefuljournal.online/',
|
||||
})
|
||||
const { user, loading, signInWithGoogle, authError } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [signingIn, setSigningIn] = useState(false)
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { usePageMeta } from '../hooks/usePageMeta'
|
||||
|
||||
export default function PrivacyPage() {
|
||||
usePageMeta({
|
||||
title: 'Privacy Policy — Grateful Journal',
|
||||
description: 'Grateful Journal\'s privacy policy. Your journal entries are end-to-end encrypted — we cannot read them. No ads, no tracking, no data selling.',
|
||||
canonical: 'https://gratefuljournal.online/privacy',
|
||||
ogTitle: 'Privacy Policy — Grateful Journal',
|
||||
ogDescription: 'Your journal entries are end-to-end encrypted and private. We cannot read them, we don\'t sell your data, and we use no advertising cookies.',
|
||||
})
|
||||
return (
|
||||
<div className="static-page">
|
||||
<header className="static-page__header">
|
||||
|
||||
@@ -7,6 +7,10 @@ import BottomNav from '../components/BottomNav'
|
||||
import { useOnboardingTour, hasPendingTourStep, clearPendingTourStep } from '../hooks/useOnboardingTour'
|
||||
import { PageLoader } from '../components/PageLoader'
|
||||
import { usePWAInstall } from '../hooks/usePWAInstall'
|
||||
import {
|
||||
getSavedReminderTime, isReminderEnabled,
|
||||
enableReminder, disableReminder, reenableReminder,
|
||||
} from '../hooks/useReminder'
|
||||
|
||||
const MAX_PHOTO_SIZE = 200 // px — resize to 200x200
|
||||
|
||||
@@ -55,6 +59,14 @@ export default function SettingsPage() {
|
||||
const { canInstall, isIOS, triggerInstall } = usePWAInstall()
|
||||
const [installModal, setInstallModal] = useState<'ios' | 'chrome' | null>(null)
|
||||
|
||||
// Reminder state
|
||||
const [reminderTime, setReminderTime] = useState<string | null>(() => getSavedReminderTime())
|
||||
const [reminderEnabled, setReminderEnabled] = useState(() => isReminderEnabled())
|
||||
const [showReminderModal, setShowReminderModal] = useState(false)
|
||||
const [reminderPickedTime, setReminderPickedTime] = useState('08:00')
|
||||
const [reminderError, setReminderError] = useState<string | null>(null)
|
||||
const [reminderSaving, setReminderSaving] = useState(false)
|
||||
|
||||
// Edit profile modal state
|
||||
const [showEditModal, setShowEditModal] = useState(false)
|
||||
const [editName, setEditName] = useState('')
|
||||
@@ -182,6 +194,56 @@ export default function SettingsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenReminderModal = () => {
|
||||
setReminderPickedTime(reminderTime || '08:00')
|
||||
setReminderError(null)
|
||||
setShowReminderModal(true)
|
||||
}
|
||||
|
||||
const handleSaveReminder = async () => {
|
||||
if (!user || !userId) return
|
||||
setReminderSaving(true)
|
||||
setReminderError(null)
|
||||
const authToken = await user.getIdToken()
|
||||
const error = await enableReminder(reminderPickedTime, userId, authToken)
|
||||
setReminderSaving(false)
|
||||
if (error) {
|
||||
setReminderError(error)
|
||||
} else {
|
||||
setReminderTime(reminderPickedTime)
|
||||
setReminderEnabled(true)
|
||||
setShowReminderModal(false)
|
||||
setMessage({ type: 'success', text: 'Reminder set!' })
|
||||
setTimeout(() => setMessage(null), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReminderToggle = async () => {
|
||||
if (!user || !userId) return
|
||||
if (!reminderTime) {
|
||||
handleOpenReminderModal()
|
||||
return
|
||||
}
|
||||
if (reminderEnabled) {
|
||||
const authToken = await user.getIdToken()
|
||||
await disableReminder(userId, authToken)
|
||||
setReminderEnabled(false)
|
||||
} else {
|
||||
setReminderSaving(true)
|
||||
const authToken = await user.getIdToken()
|
||||
const error = await reenableReminder(userId, authToken)
|
||||
setReminderSaving(false)
|
||||
if (error) {
|
||||
setReminderError(error)
|
||||
setShowReminderModal(true)
|
||||
} else {
|
||||
setReminderEnabled(true)
|
||||
setMessage({ type: 'success', text: 'Reminder enabled!' })
|
||||
setTimeout(() => setMessage(null), 2000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleSignOut = async () => {
|
||||
try {
|
||||
await signOut()
|
||||
@@ -318,6 +380,40 @@ export default function SettingsPage() {
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="settings-divider"></div>
|
||||
|
||||
{/* Daily Reminder */}
|
||||
<div className="settings-item">
|
||||
<div className="settings-item-icon settings-item-icon-orange">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
|
||||
</svg>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="settings-item-content"
|
||||
onClick={handleOpenReminderModal}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', textAlign: 'left', padding: 0 }}
|
||||
>
|
||||
<h4 className="settings-item-title">Daily Reminder</h4>
|
||||
<p className="settings-item-subtitle">
|
||||
{reminderTime
|
||||
? (reminderEnabled ? `Reminds you at ${reminderTime}` : `Set to ${reminderTime} — paused`)
|
||||
: 'Tap to set a daily reminder'}
|
||||
</p>
|
||||
</button>
|
||||
<label className="settings-toggle" title={reminderEnabled ? 'Disable reminder' : 'Enable reminder'}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={reminderEnabled}
|
||||
onChange={handleReminderToggle}
|
||||
disabled={reminderSaving}
|
||||
/>
|
||||
<span className="settings-toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -622,6 +718,73 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Daily Reminder Modal */}
|
||||
{showReminderModal && (
|
||||
<div className="confirm-modal-overlay" onClick={() => !reminderSaving && setShowReminderModal(false)}>
|
||||
<div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div style={{ fontSize: '2rem', textAlign: 'center', marginBottom: '0.5rem' }}>🔔</div>
|
||||
<h3 className="confirm-modal-title">
|
||||
{reminderTime ? 'Edit Reminder' : 'Set Daily Reminder'}
|
||||
</h3>
|
||||
<p className="confirm-modal-desc">
|
||||
You'll get a notification at this time each day if you haven't written yet.
|
||||
{' '}The reminder stays even if you log out and back in.
|
||||
</p>
|
||||
|
||||
<label className="confirm-modal-label">Reminder time</label>
|
||||
<input
|
||||
type="time"
|
||||
className="confirm-modal-input"
|
||||
value={reminderPickedTime}
|
||||
onChange={(e) => setReminderPickedTime(e.target.value)}
|
||||
disabled={reminderSaving}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{reminderError && (
|
||||
<p style={{
|
||||
color: 'var(--color-error, #ef4444)',
|
||||
fontSize: '0.8rem',
|
||||
marginTop: '0.5rem',
|
||||
textAlign: 'center',
|
||||
lineHeight: 1.4,
|
||||
}}>
|
||||
{reminderError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p style={{
|
||||
fontSize: '0.75rem',
|
||||
color: 'var(--color-text-muted)',
|
||||
marginTop: '0.75rem',
|
||||
textAlign: 'center',
|
||||
lineHeight: 1.5,
|
||||
}}>
|
||||
Works best when the app is installed on your home screen.
|
||||
</p>
|
||||
|
||||
<div className="confirm-modal-actions" style={{ marginTop: '1rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="confirm-modal-cancel"
|
||||
onClick={() => setShowReminderModal(false)}
|
||||
disabled={reminderSaving}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="edit-modal-save"
|
||||
onClick={handleSaveReminder}
|
||||
disabled={reminderSaving || !reminderPickedTime}
|
||||
>
|
||||
{reminderSaving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<BottomNav />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,17 +1,48 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
function swBuildTimePlugin() {
|
||||
function injectFirebaseConfig(content: string, env: Record<string, string>): string {
|
||||
return content
|
||||
.replace('__VITE_FIREBASE_API_KEY__', env.VITE_FIREBASE_API_KEY || '')
|
||||
.replace('__VITE_FIREBASE_AUTH_DOMAIN__', env.VITE_FIREBASE_AUTH_DOMAIN || '')
|
||||
.replace('__VITE_FIREBASE_PROJECT_ID__', env.VITE_FIREBASE_PROJECT_ID || '')
|
||||
.replace('__VITE_FIREBASE_MESSAGING_SENDER_ID__', env.VITE_FIREBASE_MESSAGING_SENDER_ID || '')
|
||||
.replace('__VITE_FIREBASE_APP_ID__', env.VITE_FIREBASE_APP_ID || '')
|
||||
}
|
||||
|
||||
function swPlugin() {
|
||||
let env: Record<string, string> = {}
|
||||
|
||||
return {
|
||||
name: 'sw-build-time',
|
||||
name: 'sw-plugin',
|
||||
config(_: unknown, { mode }: { mode: string }) {
|
||||
env = loadEnv(mode, process.cwd(), '')
|
||||
},
|
||||
// Dev server: serve firebase-messaging-sw.js with injected config
|
||||
configureServer(server: { middlewares: { use: (path: string, handler: (req: unknown, res: { setHeader: (k: string, v: string) => void; end: (s: string) => void }, next: () => void) => void) => void } }) {
|
||||
server.middlewares.use('/firebase-messaging-sw.js', (_req, res) => {
|
||||
const swPath = path.resolve(__dirname, 'public/firebase-messaging-sw.js')
|
||||
if (fs.existsSync(swPath)) {
|
||||
const content = injectFirebaseConfig(fs.readFileSync(swPath, 'utf-8'), env)
|
||||
res.setHeader('Content-Type', 'application/javascript')
|
||||
res.end(content)
|
||||
}
|
||||
})
|
||||
},
|
||||
closeBundle() {
|
||||
// Cache-bust the main service worker
|
||||
const swPath = path.resolve(__dirname, 'dist/sw.js')
|
||||
if (fs.existsSync(swPath)) {
|
||||
const content = fs.readFileSync(swPath, 'utf-8')
|
||||
const timestamp = Date.now().toString()
|
||||
fs.writeFileSync(swPath, content.replace('__BUILD_TIME__', timestamp))
|
||||
fs.writeFileSync(swPath, content.replace('__BUILD_TIME__', Date.now().toString()))
|
||||
}
|
||||
// Inject Firebase config into the FCM service worker
|
||||
const fswPath = path.resolve(__dirname, 'dist/firebase-messaging-sw.js')
|
||||
if (fs.existsSync(fswPath)) {
|
||||
const content = injectFirebaseConfig(fs.readFileSync(fswPath, 'utf-8'), env)
|
||||
fs.writeFileSync(fswPath, content)
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -19,7 +50,7 @@ function swBuildTimePlugin() {
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), swBuildTimePlugin()],
|
||||
plugins: [react(), swPlugin()],
|
||||
server: {
|
||||
port: 8000,
|
||||
strictPort: false,
|
||||
@@ -27,5 +58,30 @@ export default defineConfig({
|
||||
optimizeDeps: {
|
||||
include: ['libsodium-wrappers'],
|
||||
},
|
||||
build: {
|
||||
chunkSizeWarningLimit: 1000,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (id.includes('node_modules/firebase')) {
|
||||
return 'firebase'
|
||||
}
|
||||
if (
|
||||
id.includes('node_modules/react/') ||
|
||||
id.includes('node_modules/react-dom/') ||
|
||||
id.includes('node_modules/react-router-dom/')
|
||||
) {
|
||||
return 'react-vendor'
|
||||
}
|
||||
if (id.includes('node_modules/libsodium')) {
|
||||
return 'crypto'
|
||||
}
|
||||
if (id.includes('node_modules/driver.js')) {
|
||||
return 'driver'
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user