Compare commits
21 Commits
df4bb88f70
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 05fcb0a0d5 | |||
| d6da8177c1 | |||
| 237ba6b3c1 | |||
| 93dbf2023c | |||
| 85477e5499 | |||
| 7f06fa347a | |||
| 11940678f7 | |||
| bf7245d6d1 | |||
| 816476ed02 | |||
| 6e906436cc | |||
| 84019c3881 | |||
| 09464aaa96 | |||
| 7d60fe4634 | |||
| 07a72d6c9f | |||
| d183cf2fd6 | |||
| 19dcd73b29 | |||
| a1ac8e7933 | |||
| 4d3a0ca1bd | |||
| 937a98c58d | |||
| 1353dfc69d | |||
| 34254f94f9 |
@@ -15,7 +15,15 @@
|
||||
"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:$?\")",
|
||||
"Bash(python -c \"from config import get_settings; s = get_settings\\(\\); print\\('SA JSON set:', bool\\(s.firebase_service_account_json\\)\\)\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ ARG VITE_FIREBASE_PROJECT_ID
|
||||
ARG VITE_FIREBASE_STORAGE_BUCKET
|
||||
ARG VITE_FIREBASE_MESSAGING_SENDER_ID
|
||||
ARG VITE_FIREBASE_APP_ID
|
||||
ARG VITE_FIREBASE_VAPID_KEY
|
||||
ARG VITE_API_URL=/api
|
||||
|
||||
ENV VITE_FIREBASE_API_KEY=${VITE_FIREBASE_API_KEY}
|
||||
@@ -16,6 +17,7 @@ ENV VITE_FIREBASE_PROJECT_ID=${VITE_FIREBASE_PROJECT_ID}
|
||||
ENV VITE_FIREBASE_STORAGE_BUCKET=${VITE_FIREBASE_STORAGE_BUCKET}
|
||||
ENV VITE_FIREBASE_MESSAGING_SENDER_ID=${VITE_FIREBASE_MESSAGING_SENDER_ID}
|
||||
ENV VITE_FIREBASE_APP_ID=${VITE_FIREBASE_APP_ID}
|
||||
ENV VITE_FIREBASE_VAPID_KEY=${VITE_FIREBASE_VAPID_KEY}
|
||||
ENV VITE_API_URL=${VITE_API_URL}
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
|
||||
328
REMINDER_FEATURE_SETUP.md
Normal file
328
REMINDER_FEATURE_SETUP.md
Normal file
@@ -0,0 +1,328 @@
|
||||
# Daily Reminder Feature - Complete Setup & Context
|
||||
|
||||
**Date:** 2026-04-20
|
||||
**Status:** ✅ Enabled & Ready for Testing
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The Daily Reminder feature is a **fully implemented Firebase Cloud Messaging (FCM)** system that sends push notifications to remind users to journal. It works even when the browser is closed (on mobile PWA).
|
||||
|
||||
**Key Point:** All code was already in place but disabled in the UI. This document captures the setup and what was changed to enable it.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Frontend Flow
|
||||
|
||||
**Files:** `src/hooks/useReminder.ts`, `src/hooks/reminderApi.ts`, `src/pages/SettingsPage.tsx`
|
||||
|
||||
1. User opens Settings → clicks "Daily Reminder" button
|
||||
2. Modal opens with time picker (`ClockTimePicker` component)
|
||||
3. User selects time (e.g., 08:00) → clicks "Save"
|
||||
4. `enableReminder()` is called:
|
||||
- Requests browser notification permission (`Notification.requestPermission()`)
|
||||
- Gets FCM token from service worker
|
||||
- Sends token to backend: `POST /api/notifications/fcm-token`
|
||||
- Sends settings to backend: `PUT /api/notifications/reminder/{userId}`
|
||||
- Stores time + enabled state in localStorage
|
||||
|
||||
**Message Handling:**
|
||||
|
||||
- `listenForegroundMessages()` called on app mount (in `src/main.tsx`)
|
||||
- When app is **focused**: Firebase SDK triggers `onMessage()` → shows notification manually
|
||||
- When app is **closed**: Service worker (`public/sw.js`) handles it via `onBackgroundMessage()` → shows notification
|
||||
|
||||
### Backend Flow
|
||||
|
||||
**Files:** `backend/scheduler.py`, `backend/routers/notifications.py`, `backend/main.py`
|
||||
|
||||
**Initialization:**
|
||||
|
||||
- `start_scheduler()` called in FastAPI app lifespan
|
||||
- Initializes Firebase Admin SDK (requires `FIREBASE_SERVICE_ACCOUNT_JSON`)
|
||||
- Starts APScheduler cron job
|
||||
|
||||
**Every Minute:**
|
||||
|
||||
1. Find all users with `reminder.enabled=true` and FCM tokens
|
||||
2. For each user:
|
||||
- Convert UTC time → user's timezone (stored in DB)
|
||||
- Check if current HH:MM matches `reminder.time` (e.g., "08:00")
|
||||
- Check if already notified today (via `reminder.lastNotifiedDate`)
|
||||
- Check if user has written a journal entry today
|
||||
- **If NOT written yet:** Send FCM push via `firebase_admin.messaging.send_each_for_multicast()`
|
||||
- Auto-prune stale tokens on failure
|
||||
- Mark as notified today
|
||||
|
||||
**Database Structure (MongoDB):**
|
||||
|
||||
```js
|
||||
users collection {
|
||||
_id: ObjectId,
|
||||
fcmTokens: [token1, token2, ...], // per device
|
||||
reminder: {
|
||||
enabled: boolean,
|
||||
time: "HH:MM", // 24-hour format
|
||||
timezone: "Asia/Kolkata", // IANA timezone
|
||||
lastNotifiedDate: "2026-04-16" // prevents duplicates today
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Changes Made (2026-04-20)
|
||||
|
||||
### 1. Updated Frontend Environment (`.env.local`)
|
||||
|
||||
**Changed:** Firebase credentials from mentor's project → personal test project
|
||||
|
||||
```env
|
||||
VITE_FIREBASE_API_KEY=AIzaSyAjGq7EFrp1mE_8Ni2iZz8LNk7ySVz-lX8
|
||||
VITE_FIREBASE_AUTH_DOMAIN=react-test-8cb04.firebaseapp.com
|
||||
VITE_FIREBASE_PROJECT_ID=react-test-8cb04
|
||||
VITE_FIREBASE_MESSAGING_SENDER_ID=1036594341832
|
||||
VITE_FIREBASE_APP_ID=1:1036594341832:web:9db6fa337e9cd2e953c2fd
|
||||
VITE_FIREBASE_VAPID_KEY=BLXhAWY-ms-ACW4PFpqnPak3VZobBIruylVE8Jt-Gm4x53g4aAzEhQzjTvGW8O7dX76-ZoUjlBV15b-EODr1IaY
|
||||
```
|
||||
|
||||
### 2. Updated Backend Environment (`backend/.env`)
|
||||
|
||||
**Changed:** Added Firebase service account JSON (from personal test project)
|
||||
|
||||
```env
|
||||
FIREBASE_SERVICE_ACCOUNT_JSON={"type":"service_account","project_id":"react-test-8cb04",...}
|
||||
```
|
||||
|
||||
### 3. Deleted Service Account JSON File
|
||||
|
||||
- Removed: `service account.json` (no longer needed — credentials now in env var)
|
||||
|
||||
### 4. Enabled Reminder UI (`src/pages/SettingsPage.tsx`)
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
<div className="settings-item" style={{ opacity: 0.5 }}>
|
||||
<label className="settings-toggle">
|
||||
<input type="checkbox" checked={false} disabled readOnly />
|
||||
</label>
|
||||
</div>
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
<button
|
||||
type="button"
|
||||
className="settings-item settings-item-button"
|
||||
onClick={handleOpenReminderModal}
|
||||
>
|
||||
<div className="settings-item-content">
|
||||
<h4 className="settings-item-title">Daily Reminder</h4>
|
||||
<p className="settings-item-subtitle">
|
||||
{reminderEnabled && reminderTime
|
||||
? `Set for ${reminderTime}`
|
||||
: "Set a daily reminder"}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
```
|
||||
|
||||
- Changed from disabled toggle → interactive button
|
||||
- Shows current reminder time or "Set a daily reminder"
|
||||
- Clicking opens time picker modal
|
||||
|
||||
### 5. Removed Type Ignore Comment
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
// @ts-ignore — intentionally unused, reminder is disabled (coming soon)
|
||||
const handleReminderToggle = async () => {
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
const handleReminderToggle = async () => {
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Critical Code Files
|
||||
|
||||
| File | Purpose |
|
||||
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| `src/hooks/useReminder.ts` | `enableReminder()`, `disableReminder()`, `reenableReminder()`, `getFcmToken()`, `listenForegroundMessages()` |
|
||||
| `src/hooks/reminderApi.ts` | `saveFcmToken()`, `saveReminderSettings()` |
|
||||
| `backend/scheduler.py` | `send_reminder_notifications()`, `_process_user()`, `_send_push()`, `init_firebase()` |
|
||||
| `backend/routers/notifications.py` | `POST /fcm-token`, `PUT /reminder/{user_id}` endpoints |
|
||||
| `public/sw.js` | Service worker background message handler |
|
||||
| `src/pages/SettingsPage.tsx` | UI: time picker modal, reminder state mgmt |
|
||||
| `src/main.tsx` | Calls `listenForegroundMessages()` on mount |
|
||||
| `backend/main.py` | Scheduler initialization in app lifespan |
|
||||
|
||||
---
|
||||
|
||||
## How to Test
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- ✅ Backend `.env` has Firebase service account JSON
|
||||
- ✅ Frontend `.env.local` has Firebase web config + VAPID key
|
||||
- ✅ UI is enabled (button visible in Settings)
|
||||
|
||||
### Steps
|
||||
|
||||
1. **Restart the backend** (so it picks up new `FIREBASE_SERVICE_ACCOUNT_JSON`)
|
||||
|
||||
```bash
|
||||
docker-compose down
|
||||
docker-compose up
|
||||
```
|
||||
|
||||
2. **Open the app** and go to **Settings**
|
||||
|
||||
3. **Click "Daily Reminder"** → time picker modal opens
|
||||
|
||||
4. **Pick a time** (e.g., 14:30 for testing: pick a time 1-2 minutes in the future)
|
||||
|
||||
5. **Click "Save"**
|
||||
- Browser asks for notification permission → Accept
|
||||
- Time is saved locally + sent to backend
|
||||
|
||||
6. **Monitor backend logs:**
|
||||
|
||||
```bash
|
||||
docker logs grateful-journal-backend-1 -f
|
||||
```
|
||||
|
||||
Look for: `Reminder sent to user {user_id}: X ok, 0 failed`
|
||||
|
||||
7. **At the reminder time:**
|
||||
- If browser is open: notification appears in-app
|
||||
- If browser is closed: PWA/OS notification appears (mobile)
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
| Issue | Solution |
|
||||
| --------------------------------------------------- | ---------------------------------------------------------------------------------- |
|
||||
| Browser asks for notification permission repeatedly | Check `Notification.permission === 'default'` in browser console |
|
||||
| FCM token is null | Check `VITE_FIREBASE_VAPID_KEY` is correct; browser may not support FCM |
|
||||
| Scheduler doesn't run | Restart backend; check `FIREBASE_SERVICE_ACCOUNT_JSON` is valid JSON |
|
||||
| Notification doesn't appear | Check `reminder.lastNotifiedDate` in MongoDB; trigger time must match exactly |
|
||||
| Token registration fails | Check backend logs; 400 error means invalid userId format (must be valid ObjectId) |
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables Reference
|
||||
|
||||
### Frontend (`.env.local`)
|
||||
|
||||
```
|
||||
VITE_FIREBASE_API_KEY # Firebase API key
|
||||
VITE_FIREBASE_AUTH_DOMAIN # Firebase auth domain
|
||||
VITE_FIREBASE_PROJECT_ID # Firebase project ID
|
||||
VITE_FIREBASE_MESSAGING_SENDER_ID # Firebase sender ID
|
||||
VITE_FIREBASE_APP_ID # Firebase app ID
|
||||
VITE_FIREBASE_VAPID_KEY # FCM Web Push VAPID key (from Firebase Console → Messaging)
|
||||
VITE_API_URL # Backend API URL (e.g., http://localhost:8001/api)
|
||||
```
|
||||
|
||||
### Backend (`backend/.env`)
|
||||
|
||||
```
|
||||
FIREBASE_SERVICE_ACCOUNT_JSON # Entire Firebase service account JSON (minified single line)
|
||||
MONGODB_URI # MongoDB connection string
|
||||
MONGODB_DB_NAME # Database name
|
||||
API_PORT # Backend port
|
||||
ENVIRONMENT # production/development
|
||||
FRONTEND_URL # Frontend URL for CORS
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
### For Production
|
||||
|
||||
- Switch back to mentor's Firebase credentials (remove personal test project)
|
||||
- Update `.env.local` and `backend/.env` with production Firebase values
|
||||
|
||||
### Future Improvements
|
||||
|
||||
- Add UI toggle to enable/disable without removing settings
|
||||
- Show timezone in Settings (currently auto-detected)
|
||||
- Show last notification date in UI
|
||||
- Add snooze button to notifications
|
||||
- Let users set multiple reminder times
|
||||
|
||||
### Resetting to Disabled State
|
||||
|
||||
If you need to disable reminders again:
|
||||
|
||||
1. Revert `.env.local` and `backend/.env` to mentor's credentials
|
||||
2. Revert `src/pages/SettingsPage.tsx` to show "Coming soon" UI
|
||||
3. Add back `@ts-ignore` comment
|
||||
|
||||
---
|
||||
|
||||
## Technical Notes
|
||||
|
||||
### Why This Approach?
|
||||
|
||||
- **FCM:** Works on web, mobile, PWA; no polling needed
|
||||
- **Service Worker:** Handles background notifications even when browser closed
|
||||
- **Timezone:** Stores user's IANA timezone to support global users
|
||||
- **Duplicate Prevention:** Tracks `lastNotifiedDate` per user
|
||||
- **Smart Timing:** Only notifies if user hasn't written today (no spam)
|
||||
|
||||
### Security Considerations
|
||||
|
||||
- Firebase service account JSON should never be in git (only in env vars)
|
||||
- FCM tokens are device-specific; backend stores them securely
|
||||
- All reminder data is encrypted end-to-end (matches app's crypto design)
|
||||
|
||||
### Known Limitations
|
||||
|
||||
- Reminder check runs every minute (not more frequent)
|
||||
- FCM token refresh is handled by Firebase SDK automatically
|
||||
- Stale tokens are auto-pruned on failed sends
|
||||
- Timezone must be valid IANA format (not GMT±X)
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference Commands
|
||||
|
||||
**Check backend scheduler logs:**
|
||||
|
||||
```bash
|
||||
docker logs grateful-journal-backend-1 -f | grep -i "reminder\|firebase"
|
||||
```
|
||||
|
||||
**View user reminders in MongoDB:**
|
||||
|
||||
```bash
|
||||
docker exec grateful-journal-mongo-1 mongosh grateful_journal --eval "db.users.findOne({_id: ObjectId('...')})" --username admin --password internvps
|
||||
```
|
||||
|
||||
**Clear FCM tokens for a user (testing):**
|
||||
|
||||
```bash
|
||||
docker exec grateful-journal-mongo-1 mongosh grateful_journal --eval "db.users.updateOne({_id: ObjectId('...')}, {\$set: {fcmTokens: []}})" --username admin --password internvps
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
For questions about:
|
||||
|
||||
- **Reminders:** Check daily_reminder_feature.md in memory
|
||||
- **FCM:** Firebase Cloud Messaging docs
|
||||
- **APScheduler:** APScheduler documentation
|
||||
- **Firebase Admin SDK:** Firebase Admin SDK for Python docs
|
||||
15
TODO.md
15
TODO.md
@@ -1,15 +0,0 @@
|
||||
# TODO
|
||||
|
||||
## 1. Tree Growing Animation on Journal Save
|
||||
When a new journal entry is saved, show a tree growing animation — a visual metaphor for gratitude growing over time.
|
||||
- Trigger animation after successful save
|
||||
- Tree sprouts from seed → sapling → full tree
|
||||
- Could use CSS/SVG animation or a canvas-based approach
|
||||
- Consider making it dismissible / auto-fade after completion
|
||||
|
||||
## 2. Smoother Google Auth Flow
|
||||
Improve the UX of the Google OAuth flow.
|
||||
- Reduce redirect friction (loading states, transitions)
|
||||
- Show a proper loading screen during the OAuth callback
|
||||
- Handle errors gracefully with user-friendly messages
|
||||
- Consider persisting intent so users land back where they started
|
||||
115
about.html
Normal file
115
about.html
Normal file
@@ -0,0 +1,115 @@
|
||||
<!doctype html>
|
||||
<html lang="en" style="background-color:#eef6ee">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="Grateful Journal" />
|
||||
<meta name="theme-color" content="#16a34a" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
|
||||
/>
|
||||
|
||||
<!-- SEO -->
|
||||
<title>About Grateful Journal | Private, Encrypted Gratitude Journaling</title>
|
||||
<meta name="description" content="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." />
|
||||
<meta name="keywords" content="about grateful journal, private gratitude journal, encrypted journal app, gratitude journaling, mindfulness app" />
|
||||
<meta name="robots" content="index, follow, max-snippet:160, max-image-preview:large" />
|
||||
<link rel="canonical" href="https://gratefuljournal.online/about" />
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<meta property="og:url" content="https://gratefuljournal.online/about" />
|
||||
<meta property="og:title" content="About Grateful Journal | Private, Encrypted Gratitude Journaling" />
|
||||
<meta property="og:description" content="A free, private gratitude journal with end-to-end encryption. Learn how we built a distraction-free space for your daily reflection practice." />
|
||||
<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="About Grateful Journal | Private, Encrypted Gratitude Journaling" />
|
||||
<meta name="twitter:description" content="A free, private gratitude journal with end-to-end encryption. No ads, no tracking, no social feed." />
|
||||
<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: WebPage -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "AboutPage",
|
||||
"name": "About Grateful Journal",
|
||||
"url": "https://gratefuljournal.online/about",
|
||||
"description": "Learn about Grateful Journal — a free, end-to-end encrypted daily gratitude journal. No ads, no tracking, no social feed.",
|
||||
"isPartOf": {
|
||||
"@type": "WebSite",
|
||||
"name": "Grateful Journal",
|
||||
"url": "https://gratefuljournal.online/"
|
||||
}
|
||||
}
|
||||
</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>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<noscript>
|
||||
<main style="font-family:sans-serif;max-width:680px;margin:4rem auto;padding:1rem 1.5rem;color:#1a1a1a;line-height:1.7">
|
||||
<nav style="margin-bottom:2rem"><a href="/" style="color:#15803d">← Grateful Journal</a></nav>
|
||||
|
||||
<h1 style="color:#15803d">About Grateful Journal</h1>
|
||||
<p style="font-size:1.1rem">A private space for gratitude and reflection. No feeds. No noise. Just you and your thoughts.</p>
|
||||
|
||||
<h2>What is it?</h2>
|
||||
<p>Grateful Journal is a free, end-to-end encrypted daily journal focused on gratitude. You write a few things you're grateful for each day, and over time you build a private record of the good in your life — visible only to you.</p>
|
||||
|
||||
<h2>Features</h2>
|
||||
<ul>
|
||||
<li><strong>End-to-end encrypted entries</strong> — your journal content is encrypted before leaving your device. We cannot read it.</li>
|
||||
<li><strong>No ads, no tracking</strong> — we don't sell your data or show you ads.</li>
|
||||
<li><strong>Works offline</strong> — installable as a PWA on Android, iOS, and desktop.</li>
|
||||
<li><strong>Daily prompts</strong> — gentle nudges to keep your practice consistent.</li>
|
||||
<li><strong>History view</strong> — browse past entries and reflect on how far you've come.</li>
|
||||
<li><strong>Free to use</strong> — no subscription, no paywall.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Why gratitude?</h2>
|
||||
<p>Research consistently shows that a regular gratitude practice improves mood, reduces stress, and builds resilience. Grateful Journal gives you the simplest possible tool to build that habit — without distractions or social pressure.</p>
|
||||
|
||||
<h2>Privacy first</h2>
|
||||
<p>We built Grateful Journal because we believe your inner thoughts deserve a private space. Your journal entries are end-to-end encrypted — only you can read them. App preferences such as your display name, profile photo, and background images are stored as plain account settings and are not encrypted. Read our full <a href="/privacy">Privacy Policy</a> for a complete breakdown of what is and isn't encrypted.</p>
|
||||
|
||||
<nav style="margin-top:2rem">
|
||||
<a href="/">← Back to Grateful Journal</a> ·
|
||||
<a href="/privacy">Privacy Policy</a>
|
||||
</nav>
|
||||
</main>
|
||||
</noscript>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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.
Binary file not shown.
@@ -1,5 +1,8 @@
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict # type: ignore
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
_ENV_FILE = str(Path(__file__).parent / ".env")
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
@@ -8,9 +11,11 @@ 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",
|
||||
env_file=_ENV_FILE,
|
||||
case_sensitive=False,
|
||||
extra="ignore", # ignore unknown env vars (e.g. VITE_* from root .env)
|
||||
)
|
||||
|
||||
@@ -1,19 +1,34 @@
|
||||
from fastapi import FastAPI, HTTPException, Depends
|
||||
import logging
|
||||
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
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
force=True,
|
||||
)
|
||||
logging.getLogger("scheduler").setLevel(logging.DEBUG)
|
||||
|
||||
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 +58,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")
|
||||
|
||||
@@ -39,6 +39,8 @@ class UserUpdate(BaseModel):
|
||||
photoURL: Optional[str] = None
|
||||
theme: Optional[str] = None
|
||||
tutorial: Optional[bool] = None
|
||||
backgroundImage: Optional[str] = None
|
||||
backgroundImages: Optional[List[str]] = None
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
|
||||
@@ -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"}
|
||||
@@ -52,6 +52,9 @@ async def register_user(user_data: UserCreate):
|
||||
"displayName": user["displayName"],
|
||||
"photoURL": user.get("photoURL"),
|
||||
"theme": user.get("theme", "light"),
|
||||
"backgroundImage": user.get("backgroundImage"),
|
||||
"backgroundImages": user.get("backgroundImages", []),
|
||||
"reminder": user.get("reminder"),
|
||||
"createdAt": user["createdAt"].isoformat(),
|
||||
"updatedAt": user["updatedAt"].isoformat(),
|
||||
"message": "User registered successfully" if result.upserted_id else "User already exists"
|
||||
@@ -79,6 +82,9 @@ async def get_user_by_email(email: str):
|
||||
"displayName": user.get("displayName"),
|
||||
"photoURL": user.get("photoURL"),
|
||||
"theme": user.get("theme", "light"),
|
||||
"backgroundImage": user.get("backgroundImage"),
|
||||
"backgroundImages": user.get("backgroundImages", []),
|
||||
"reminder": user.get("reminder"),
|
||||
"tutorial": user.get("tutorial"),
|
||||
"createdAt": user["createdAt"].isoformat(),
|
||||
"updatedAt": user["updatedAt"].isoformat()
|
||||
@@ -111,6 +117,8 @@ async def get_user_by_id(user_id: str):
|
||||
"displayName": user.get("displayName"),
|
||||
"photoURL": user.get("photoURL"),
|
||||
"theme": user.get("theme", "light"),
|
||||
"backgroundImage": user.get("backgroundImage"),
|
||||
"backgroundImages": user.get("backgroundImages", []),
|
||||
"createdAt": user["createdAt"].isoformat(),
|
||||
"updatedAt": user["updatedAt"].isoformat()
|
||||
}
|
||||
@@ -152,6 +160,8 @@ async def update_user(user_id: str, user_data: UserUpdate):
|
||||
"displayName": user.get("displayName"),
|
||||
"photoURL": user.get("photoURL"),
|
||||
"theme": user.get("theme", "light"),
|
||||
"backgroundImage": user.get("backgroundImage"),
|
||||
"backgroundImages": user.get("backgroundImages", []),
|
||||
"tutorial": user.get("tutorial"),
|
||||
"createdAt": user["createdAt"].isoformat(),
|
||||
"updatedAt": user["updatedAt"].isoformat(),
|
||||
|
||||
202
backend/scheduler.py
Normal file
202
backend/scheduler.py
Normal file
@@ -0,0 +1,202 @@
|
||||
"""
|
||||
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:
|
||||
log.warning("Reminder check skipped — Firebase not initialized")
|
||||
return
|
||||
|
||||
db = get_database()
|
||||
now_utc = datetime.utcnow().replace(second=0, microsecond=0)
|
||||
|
||||
candidates = list(db.users.find({
|
||||
"reminder.enabled": True,
|
||||
"fcmTokens": {"$exists": True, "$not": {"$size": 0}},
|
||||
}))
|
||||
|
||||
log.debug(f"Reminder check at {now_utc.strftime('%H:%M')} UTC — {len(candidates)} candidate(s)")
|
||||
|
||||
for user in candidates:
|
||||
try:
|
||||
if user.get("reminder", {}).get("time"):
|
||||
_process_user(db, user, now_utc)
|
||||
_process_universal(db, user, now_utc)
|
||||
except Exception as e:
|
||||
log.error(f"Error processing reminder for user {user.get('_id')}: {e}")
|
||||
|
||||
|
||||
def _get_user_local_time(now_utc: datetime, timezone_str: str):
|
||||
"""Returns (now_local, today_str, user_tz)."""
|
||||
try:
|
||||
user_tz = pytz.timezone(timezone_str)
|
||||
except pytz.UnknownTimeZoneError:
|
||||
user_tz = pytz.utc
|
||||
now_local = now_utc.replace(tzinfo=pytz.utc).astimezone(user_tz)
|
||||
today_str = now_local.strftime("%Y-%m-%d")
|
||||
return now_local, today_str, user_tz
|
||||
|
||||
|
||||
def _wrote_today(db, user_id, now_local, user_tz) -> bool:
|
||||
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)
|
||||
return db.entries.count_documents({
|
||||
"userId": user_id,
|
||||
"createdAt": {"$gte": today_start_utc, "$lt": today_end_utc},
|
||||
}) > 0
|
||||
|
||||
|
||||
def _process_user(db, user: dict, now_utc: datetime):
|
||||
uid = user.get("_id")
|
||||
reminder = user.get("reminder", {})
|
||||
reminder_time_str = reminder.get("time")
|
||||
timezone_str = reminder.get("timezone", "UTC")
|
||||
fcm_tokens: list = user.get("fcmTokens", [])
|
||||
|
||||
if not reminder_time_str or not fcm_tokens:
|
||||
return
|
||||
|
||||
now_local, today_str, user_tz = _get_user_local_time(now_utc, timezone_str)
|
||||
current_hm = now_local.strftime("%H:%M")
|
||||
|
||||
if current_hm != reminder_time_str:
|
||||
log.debug(f"User {uid}: skipped — current time {current_hm} != reminder time {reminder_time_str} ({timezone_str})")
|
||||
return
|
||||
|
||||
if _wrote_today(db, uid, now_local, user_tz):
|
||||
log.debug(f"User {uid}: skipped — already wrote today")
|
||||
return
|
||||
|
||||
log.info(f"User {uid}: sending reminder (time={reminder_time_str}, tz={timezone_str})")
|
||||
_send_push(uid, fcm_tokens, db)
|
||||
|
||||
|
||||
def _process_universal(db, user: dict, now_utc: datetime):
|
||||
"""Universal 11pm reminder — fires if enabled and no entry written today."""
|
||||
uid = user.get("_id")
|
||||
reminder = user.get("reminder", {})
|
||||
timezone_str = reminder.get("timezone", "UTC")
|
||||
fcm_tokens: list = user.get("fcmTokens", [])
|
||||
|
||||
if not fcm_tokens:
|
||||
return
|
||||
|
||||
now_local, today_str, user_tz = _get_user_local_time(now_utc, timezone_str)
|
||||
|
||||
if now_local.strftime("%H:%M") != "23:00":
|
||||
return
|
||||
|
||||
if reminder.get("lastUniversalDate") == today_str:
|
||||
log.debug(f"User {uid}: universal reminder skipped — already sent today")
|
||||
return
|
||||
|
||||
if _wrote_today(db, uid, now_local, user_tz):
|
||||
log.debug(f"User {uid}: universal reminder skipped — already wrote today")
|
||||
db.users.update_one({"_id": uid}, {"$set": {"reminder.lastUniversalDate": today_str}})
|
||||
return
|
||||
|
||||
log.info(f"User {uid}: sending universal 11pm reminder (tz={timezone_str})")
|
||||
_send_push(uid, fcm_tokens, db, universal=True)
|
||||
db.users.update_one({"_id": uid}, {"$set": {"reminder.lastUniversalDate": today_str}})
|
||||
|
||||
|
||||
def _send_push(user_id, tokens: list, db, universal: bool = False):
|
||||
"""Send FCM multicast and prune stale tokens."""
|
||||
title = "Last chance to journal today 🌙" if universal else "Time to journal 🌱"
|
||||
message = messaging.MulticastMessage(
|
||||
notification=messaging.Notification(
|
||||
title=title,
|
||||
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")
|
||||
|
||||
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}")
|
||||
|
||||
|
||||
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
|
||||
@@ -10,6 +10,7 @@ services:
|
||||
VITE_FIREBASE_STORAGE_BUCKET: ${VITE_FIREBASE_STORAGE_BUCKET}
|
||||
VITE_FIREBASE_MESSAGING_SENDER_ID: ${VITE_FIREBASE_MESSAGING_SENDER_ID}
|
||||
VITE_FIREBASE_APP_ID: ${VITE_FIREBASE_APP_ID}
|
||||
VITE_FIREBASE_VAPID_KEY: ${VITE_FIREBASE_VAPID_KEY}
|
||||
VITE_API_URL: ${VITE_API_URL:-/api}
|
||||
depends_on:
|
||||
backend:
|
||||
|
||||
150
index.html
150
index.html
@@ -17,7 +17,7 @@
|
||||
/>
|
||||
|
||||
<!-- SEO -->
|
||||
<title>Grateful Journal — Your Private Gratitude Journal</title>
|
||||
<title>Private Gratitude Journal App | Grateful Journal</title>
|
||||
<meta name="description" content="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." />
|
||||
<meta name="keywords" content="gratitude journal, private journal, encrypted journal, daily gratitude, mindfulness, reflection" />
|
||||
<meta name="robots" content="index, follow, max-snippet:160, max-image-preview:large" />
|
||||
@@ -27,33 +27,56 @@
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<meta property="og:url" content="https://gratefuljournal.online/" />
|
||||
<meta property="og:title" content="Grateful Journal — Your Private Gratitude Journal" />
|
||||
<meta property="og:title" content="Private Gratitude Journal App | Grateful 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:title" content="Private Gratitude Journal App | Grateful 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/"
|
||||
},
|
||||
"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": "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."
|
||||
"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",
|
||||
@@ -68,37 +91,104 @@
|
||||
"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"
|
||||
]
|
||||
"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>
|
||||
<div id="root"></div>
|
||||
<noscript>
|
||||
<main style="font-family:sans-serif;max-width:640px;margin:4rem auto;padding:1rem;color:#1a1a1a">
|
||||
<h1>Grateful Journal — Your Private Gratitude Journal</h1>
|
||||
<p>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.</p>
|
||||
<h2>Features</h2>
|
||||
<main style="font-family:sans-serif;max-width:680px;margin:4rem auto;padding:1rem 1.5rem;color:#1a1a1a;line-height:1.7">
|
||||
<h1 style="color:#15803d">Grateful Journal - Your Private Gratitude Journal</h1>
|
||||
<p style="font-size:1.1rem">A free, private, end-to-end encrypted gratitude journal. No feeds, no noise — just you and your thoughts. Grow your gratitude one moment at a time.</p>
|
||||
|
||||
<h2>What is Grateful Journal?</h2>
|
||||
<p>Grateful Journal is a daily gratitude journaling app built for people who value privacy. You write a few things you're grateful for each day, and over time you build a private record of the good in your life — visible only to you. No social pressure, no algorithms, no distractions.</p>
|
||||
|
||||
<h2>Key Features</h2>
|
||||
<ul>
|
||||
<li>End-to-end encrypted journal entries — only you can read them</li>
|
||||
<li>Daily gratitude prompts to keep you consistent</li>
|
||||
<li>No ads, no tracking, no social feed</li>
|
||||
<li>Works offline as a Progressive Web App (PWA)</li>
|
||||
<li>Free to use</li>
|
||||
<li><strong>End-to-end encrypted entries</strong> — your journal content is encrypted on your device before it reaches our servers. We cannot read it.</li>
|
||||
<li><strong>No ads, no tracking</strong> — we do not sell your data, show ads, or use tracking pixels of any kind.</li>
|
||||
<li><strong>Works offline</strong> — installable as a Progressive Web App (PWA) on Android, iOS, and desktop. Write even without an internet connection.</li>
|
||||
<li><strong>Daily gratitude prompts</strong> — gentle nudges to keep your reflection practice consistent.</li>
|
||||
<li><strong>History view</strong> — browse past entries and see how far you've come.</li>
|
||||
<li><strong>Completely free</strong> — no subscription, no paywall, no hidden fees.</li>
|
||||
</ul>
|
||||
<h2>How it works</h2>
|
||||
<p>Sign in with Google, write a few things you're grateful for each day, and watch your mindset shift over time. Your entries are encrypted before they leave your device.</p>
|
||||
<p><a href="https://gratefuljournal.online/">Get started — it's free</a></p>
|
||||
<p>
|
||||
|
||||
<h2>Why a Private Gratitude Journal?</h2>
|
||||
<p>Research consistently shows that a regular gratitude practice improves mood, reduces stress, and builds resilience. But most journaling apps either sell your data or make your entries visible in social feeds. Grateful Journal gives you the simplest possible tool to build the gratitude habit — with your privacy as a non-negotiable foundation.</p>
|
||||
|
||||
<h2>How Encryption Works</h2>
|
||||
<p>Your journal entries are encrypted using XSalsa20-Poly1305 before leaving your device. The encryption key is derived from your account and never sent to our servers. We store only ciphertext — even a database breach would expose nothing readable. App preferences like your display name and theme are stored as plain settings, not journal content.</p>
|
||||
|
||||
<h2>Who Is It For?</h2>
|
||||
<ul>
|
||||
<li>Privacy-conscious users who want a digital journal without surveillance</li>
|
||||
<li>People building a daily gratitude or mindfulness practice</li>
|
||||
<li>Anyone who wants a distraction-free space for daily reflection</li>
|
||||
<li>Users looking for a free, encrypted alternative to Day One or Notion</li>
|
||||
</ul>
|
||||
|
||||
<h2>Frequently Asked Questions</h2>
|
||||
<dl>
|
||||
<dt><strong>Is Grateful Journal free?</strong></dt>
|
||||
<dd>Yes, completely free. No subscription, no paywall.</dd>
|
||||
<dt><strong>Are my entries private?</strong></dt>
|
||||
<dd>Yes. Entries are end-to-end encrypted. Even we cannot read them.</dd>
|
||||
<dt><strong>Does it work offline?</strong></dt>
|
||||
<dd>Yes. Install it as a PWA on Android, iOS, or desktop for offline access.</dd>
|
||||
<dt><strong>Do you sell data or show ads?</strong></dt>
|
||||
<dd>No. We do not sell data, show ads, or use any tracking.</dd>
|
||||
</dl>
|
||||
|
||||
<p><a href="https://gratefuljournal.online/" style="color:#15803d;font-weight:bold">Get started — it's free</a></p>
|
||||
<nav>
|
||||
<a href="/about">About</a> ·
|
||||
<a href="/privacy">Privacy Policy</a>
|
||||
</p>
|
||||
<a href="/privacy">Privacy Policy</a> ·
|
||||
<a href="/termsofservice">Terms of Service</a>
|
||||
</nav>
|
||||
</main>
|
||||
</noscript>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
|
||||
122
liquidglass.md
Normal file
122
liquidglass.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# Liquid Glass Theme Implementation
|
||||
|
||||
## Overview
|
||||
Replaces solid white/dark card surfaces with a unified glassmorphism effect using CSS `backdrop-filter`. No library needed — pure CSS. Works identically on both light and dark themes with only variable overrides per theme.
|
||||
|
||||
---
|
||||
|
||||
## 1. `src/index.css` changes
|
||||
|
||||
### `:root` — replace `--card-bg-opacity` + `--color-surface` with:
|
||||
```css
|
||||
--glass-bg: rgba(255, 255, 255, 0.55);
|
||||
--glass-blur: blur(18px) saturate(160%);
|
||||
--glass-border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
--glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.08);
|
||||
--color-surface: var(--glass-bg);
|
||||
```
|
||||
|
||||
### `[data-theme="dark"]` — replace `--color-surface: rgb(26 26 26 / ...)` with:
|
||||
```css
|
||||
--glass-bg: rgba(255, 255, 255, 0.07);
|
||||
--glass-border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
--glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
--color-surface: var(--glass-bg);
|
||||
```
|
||||
> `--glass-blur` is NOT redeclared in dark — it inherits the same blur from `:root`.
|
||||
|
||||
---
|
||||
|
||||
## 2. `src/App.css` additions
|
||||
|
||||
### Add this block BEFORE the `SHARED PAGE SHELL` section (~line 403):
|
||||
```css
|
||||
/* ============================
|
||||
LIQUID GLASS – applied to all card/surface elements
|
||||
============================ */
|
||||
.journal-card,
|
||||
.calendar-card,
|
||||
.entry-card,
|
||||
.entry-modal,
|
||||
.confirm-modal,
|
||||
.settings-profile,
|
||||
.settings-card,
|
||||
.settings-tutorial-btn,
|
||||
.settings-clear-btn,
|
||||
.settings-signout-btn,
|
||||
.bottom-nav,
|
||||
.lp__form {
|
||||
backdrop-filter: var(--glass-blur);
|
||||
-webkit-backdrop-filter: var(--glass-blur);
|
||||
border: var(--glass-border);
|
||||
box-shadow: var(--glass-shadow);
|
||||
}
|
||||
```
|
||||
|
||||
### Remove individual `box-shadow` from these classes (glass rule handles it):
|
||||
- `.journal-card` — remove `box-shadow: 0 2px 12px rgba(0,0,0,0.07)`
|
||||
- `.calendar-card` — remove `box-shadow: 0 2px 10px rgba(0,0,0,0.06)`
|
||||
- `.entry-card` — remove `box-shadow: 0 2px 6px rgba(0,0,0,0.05)`
|
||||
- `.settings-profile` — remove `box-shadow: 0 2px 10px rgba(0,0,0,0.06)`
|
||||
- `.settings-card` — remove `box-shadow: 0 2px 10px rgba(0,0,0,0.06)`
|
||||
|
||||
---
|
||||
|
||||
## 3. `src/App.css` dark mode cleanup
|
||||
|
||||
### Remove entire block (now redundant — glass vars handle background + shadow):
|
||||
```css
|
||||
/* -- Cards & surfaces -- */
|
||||
[data-theme="dark"] .journal-card,
|
||||
[data-theme="dark"] .calendar-card,
|
||||
[data-theme="dark"] .settings-card,
|
||||
[data-theme="dark"] .settings-profile,
|
||||
[data-theme="dark"] .entry-card {
|
||||
background: var(--color-surface);
|
||||
border-color: rgba(74, 222, 128, 0.12);
|
||||
box-shadow:
|
||||
0 2px 16px rgba(0, 0, 0, 0.4),
|
||||
0 0 0 1px rgba(74, 222, 128, 0.06);
|
||||
}
|
||||
```
|
||||
|
||||
### Collapse settings buttons dark overrides to color-only:
|
||||
```css
|
||||
/* -- Settings buttons -- */
|
||||
[data-theme="dark"] .settings-clear-btn { color: #f87171; }
|
||||
[data-theme="dark"] .settings-signout-btn { color: #9ca3af; }
|
||||
[data-theme="dark"] .settings-signout-btn:hover { color: #d1d5db; }
|
||||
```
|
||||
> Remove the full blocks that were setting `background: var(--color-surface)` and `box-shadow` for `.settings-tutorial-btn`, `.settings-clear-btn`, `.settings-signout-btn`.
|
||||
|
||||
### Entry modal dark override — keep only the border accent:
|
||||
```css
|
||||
[data-theme="dark"] .entry-modal {
|
||||
border-top-color: #4ade80;
|
||||
}
|
||||
```
|
||||
> Remove the `background` and `box-shadow` lines.
|
||||
|
||||
### Remove entirely:
|
||||
```css
|
||||
[data-theme="dark"] .delete-confirm-modal { background: var(--color-surface); }
|
||||
[data-theme="dark"] .confirm-modal { background: var(--color-surface); box-shadow: ...; }
|
||||
```
|
||||
|
||||
### History search button — keep only color:
|
||||
```css
|
||||
[data-theme="dark"] .history-search-btn { color: #7a8a7a; }
|
||||
```
|
||||
> Remove `background` and `border-color` lines.
|
||||
|
||||
---
|
||||
|
||||
## Tuning
|
||||
|
||||
| Variable | What it controls |
|
||||
|---|---|
|
||||
| `--glass-bg` opacity | How transparent the cards are (0.55 = light, 0.07 = dark) |
|
||||
| `--glass-blur` value | How much the background blurs through |
|
||||
| `--glass-border` opacity | Strength of the frosted edge highlight |
|
||||
|
||||
To make glass more/less opaque: change the alpha in `--glass-bg` in `:root` / `[data-theme="dark"]`.
|
||||
@@ -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,7 +26,22 @@ 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/ {
|
||||
client_max_body_size 5m;
|
||||
proxy_pass http://backend:8001/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
@@ -23,12 +59,26 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Known SPA routes — serve index.html
|
||||
# Homepage
|
||||
location = / {
|
||||
try_files /index.html =404;
|
||||
}
|
||||
|
||||
location ~ ^/(write|history|settings|privacy|about)(/|$) {
|
||||
# Pre-rendered public pages — each gets its own HTML with correct meta tags
|
||||
location ~ ^/about(/|$) {
|
||||
try_files /about.html =404;
|
||||
}
|
||||
|
||||
location ~ ^/privacy(/|$) {
|
||||
try_files /privacy.html =404;
|
||||
}
|
||||
|
||||
location ~ ^/termsofservice(/|$) {
|
||||
try_files /termsofservice.html =404;
|
||||
}
|
||||
|
||||
# Protected SPA routes — serve index.html (React handles auth redirect)
|
||||
location ~ ^/(write|history|settings)(/|$) {
|
||||
try_files /index.html =404;
|
||||
}
|
||||
|
||||
|
||||
103
privacy.html
Normal file
103
privacy.html
Normal file
@@ -0,0 +1,103 @@
|
||||
<!doctype html>
|
||||
<html lang="en" style="background-color:#eef6ee">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="Grateful Journal" />
|
||||
<meta name="theme-color" content="#16a34a" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
|
||||
/>
|
||||
|
||||
<!-- SEO -->
|
||||
<title>Privacy Policy | Grateful Journal</title>
|
||||
<meta name="description" content="Grateful Journal's privacy policy. Your journal entries are end-to-end encrypted — we cannot read them. No ads, no tracking, no data selling." />
|
||||
<meta name="keywords" content="grateful journal privacy policy, encrypted journal, private journal app, data privacy" />
|
||||
<meta name="robots" content="index, follow, max-snippet:160, max-image-preview:large" />
|
||||
<link rel="canonical" href="https://gratefuljournal.online/privacy" />
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<meta property="og:url" content="https://gratefuljournal.online/privacy" />
|
||||
<meta property="og:title" content="Privacy Policy | Grateful Journal" />
|
||||
<meta property="og:description" content="Your journal entries are end-to-end encrypted and private. App preferences like background images are stored unencrypted. No ads, no tracking, no data selling." />
|
||||
<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="Privacy Policy | Grateful Journal" />
|
||||
<meta name="twitter:description" content="Your journal entries are end-to-end encrypted. No ads, no tracking, no data selling." />
|
||||
<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: WebPage -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
"name": "Privacy Policy",
|
||||
"url": "https://gratefuljournal.online/privacy",
|
||||
"description": "Grateful Journal's privacy policy. Your journal entries are end-to-end encrypted — we cannot read them.",
|
||||
"isPartOf": {
|
||||
"@type": "WebSite",
|
||||
"name": "Grateful Journal",
|
||||
"url": "https://gratefuljournal.online/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<noscript>
|
||||
<main style="font-family:sans-serif;max-width:680px;margin:4rem auto;padding:1rem 1.5rem;color:#1a1a1a;line-height:1.7">
|
||||
<nav style="margin-bottom:2rem"><a href="/" style="color:#15803d">← Grateful Journal</a></nav>
|
||||
|
||||
<h1 style="color:#15803d">Privacy Policy</h1>
|
||||
<p><em>Last updated: April 14, 2026</em></p>
|
||||
|
||||
<p>Grateful Journal is built on a simple promise: your journal entries are yours alone. We designed the app so that we cannot read your entries even if we wanted to.</p>
|
||||
|
||||
<h2>What we collect</h2>
|
||||
<ul>
|
||||
<li><strong>Account info</strong> — your name and email address via Google Sign-In, used solely to identify your account.</li>
|
||||
<li><strong>Journal entries</strong> — stored encrypted in our database. We do not have access to the content of your entries.</li>
|
||||
<li><strong>App preferences</strong> — your display name, profile photo, background images, and theme are stored unencrypted as account settings.</li>
|
||||
<li><strong>Usage data</strong> — no analytics, no tracking pixels, no third-party advertising SDKs.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Encryption</h2>
|
||||
<ul>
|
||||
<li><strong>Journal entries — end-to-end encrypted.</strong> Entries are encrypted on your device using XSalsa20-Poly1305 before being sent to our servers. We store only ciphertext. We cannot read your entries.</li>
|
||||
<li><strong>App preferences — not encrypted.</strong> Your display name, profile photo, background images, and theme setting are stored as plain data.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Data sharing</h2>
|
||||
<p>We do not sell, share, or rent your personal data to any third party. We use Firebase (Google) for authentication only.</p>
|
||||
|
||||
<h2>Data deletion</h2>
|
||||
<p>You can delete your account and all associated data at any time from the Settings page. Deletion is permanent and irreversible.</p>
|
||||
|
||||
<h2>Cookies</h2>
|
||||
<p>We use a single session cookie to keep you signed in. No advertising or tracking cookies are used.</p>
|
||||
|
||||
<nav style="margin-top:2rem">
|
||||
<a href="/">← Back to Grateful Journal</a> ·
|
||||
<a href="/about">About</a>
|
||||
</nav>
|
||||
</main>
|
||||
</noscript>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,6 +1,4 @@
|
||||
User-agent: *
|
||||
Disallow: /write
|
||||
Disallow: /history
|
||||
Disallow: /settings
|
||||
Disallow:
|
||||
|
||||
Sitemap: https://gratefuljournal.online/sitemap.xml
|
||||
|
||||
@@ -2,19 +2,25 @@
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://gratefuljournal.online/</loc>
|
||||
<lastmod>2026-04-08</lastmod>
|
||||
<lastmod>2026-04-16</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://gratefuljournal.online/about</loc>
|
||||
<lastmod>2026-04-08</lastmod>
|
||||
<lastmod>2026-04-16</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://gratefuljournal.online/privacy</loc>
|
||||
<lastmod>2026-04-08</lastmod>
|
||||
<lastmod>2026-04-16</lastmod>
|
||||
<changefreq>yearly</changefreq>
|
||||
<priority>0.5</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://gratefuljournal.online/termsofservice</loc>
|
||||
<lastmod>2026-04-16</lastmod>
|
||||
<changefreq>yearly</changefreq>
|
||||
<priority>0.4</priority>
|
||||
</url>
|
||||
|
||||
47
public/sw.js
47
public/sw.js
@@ -1,3 +1,29 @@
|
||||
// Firebase Messaging — handles background push notifications
|
||||
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()
|
||||
|
||||
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',
|
||||
})
|
||||
})
|
||||
|
||||
// Cache management
|
||||
const CACHE = 'gj-__BUILD_TIME__'
|
||||
|
||||
self.addEventListener('install', (e) => {
|
||||
@@ -18,13 +44,22 @@ self.addEventListener('activate', (e) => {
|
||||
self.clients.claim()
|
||||
})
|
||||
|
||||
self.addEventListener('fetch', (e) => {
|
||||
// Only cache GET requests for same-origin non-API resources
|
||||
if (
|
||||
e.request.method !== 'GET' ||
|
||||
e.request.url.includes('/api/')
|
||||
) return
|
||||
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()
|
||||
clients[0].navigate('/')
|
||||
} else {
|
||||
self.clients.openWindow('/')
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
self.addEventListener('fetch', (e) => {
|
||||
if (e.request.method !== 'GET' || e.request.url.includes('/api/')) return
|
||||
e.respondWith(
|
||||
caches.match(e.request).then((cached) => cached || fetch(e.request))
|
||||
)
|
||||
|
||||
1144
src/App.css
1144
src/App.css
File diff suppressed because it is too large
Load Diff
25
src/App.tsx
25
src/App.tsx
@@ -1,18 +1,29 @@
|
||||
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 { useSwipeNav } from './hooks/useSwipeNav'
|
||||
import './App.css'
|
||||
|
||||
function SwipeNavHandler() {
|
||||
useSwipeNav()
|
||||
return null
|
||||
}
|
||||
|
||||
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'))
|
||||
const TermsOfServicePage = lazy(() => import('./pages/TermsOfServicePage'))
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<SwipeNavHandler />
|
||||
<Suspense fallback={null}>
|
||||
<Routes>
|
||||
<Route path="/" element={<LoginPage />} />
|
||||
<Route
|
||||
@@ -41,8 +52,10 @@ function App() {
|
||||
/>
|
||||
<Route path="/privacy" element={<PrivacyPage />} />
|
||||
<Route path="/about" element={<AboutPage />} />
|
||||
<Route path="/termsofservice" element={<TermsOfServicePage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
)
|
||||
|
||||
235
src/components/BgImageCropper.tsx
Normal file
235
src/components/BgImageCropper.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
|
||||
type HandleType = 'move' | 'tl' | 'tr' | 'bl' | 'br'
|
||||
interface CropBox { x: number; y: number; w: number; h: number }
|
||||
|
||||
interface Props {
|
||||
imageSrc: string
|
||||
aspectRatio: number // width / height of the target display area
|
||||
onCrop: (dataUrl: string) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const MIN_SIZE = 80
|
||||
|
||||
function clamp(v: number, lo: number, hi: number) {
|
||||
return Math.max(lo, Math.min(hi, v))
|
||||
}
|
||||
|
||||
export function BgImageCropper({ imageSrc, aspectRatio, onCrop, onCancel }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const imgRef = useRef<HTMLImageElement>(null)
|
||||
|
||||
// Keep crop box in both a ref (for event handlers, avoids stale closure) and state (for rendering)
|
||||
const cropRef = useRef<CropBox | null>(null)
|
||||
const [cropBox, setCropBox] = useState<CropBox | null>(null)
|
||||
|
||||
const drag = useRef<{
|
||||
type: HandleType
|
||||
startX: number
|
||||
startY: number
|
||||
startCrop: CropBox
|
||||
} | null>(null)
|
||||
|
||||
const setBox = useCallback((b: CropBox) => {
|
||||
cropRef.current = b
|
||||
setCropBox(b)
|
||||
}, [])
|
||||
|
||||
// Centre a crop box filling most of the displayed image at the target aspect ratio
|
||||
const initCrop = useCallback(() => {
|
||||
const c = containerRef.current
|
||||
const img = imgRef.current
|
||||
if (!c || !img) return
|
||||
|
||||
const cW = c.clientWidth
|
||||
const cH = c.clientHeight
|
||||
const scale = Math.min(cW / img.naturalWidth, cH / img.naturalHeight)
|
||||
const dispW = img.naturalWidth * scale
|
||||
const dispH = img.naturalHeight * scale
|
||||
const imgX = (cW - dispW) / 2
|
||||
const imgY = (cH - dispH) / 2
|
||||
|
||||
let w = dispW * 0.9
|
||||
let h = w / aspectRatio
|
||||
if (h > dispH * 0.9) { h = dispH * 0.9; w = h * aspectRatio }
|
||||
|
||||
setBox({
|
||||
x: imgX + (dispW - w) / 2,
|
||||
y: imgY + (dispH - h) / 2,
|
||||
w,
|
||||
h,
|
||||
})
|
||||
}, [aspectRatio, setBox])
|
||||
|
||||
const onPointerDown = useCallback((e: React.PointerEvent, type: HandleType) => {
|
||||
if (!cropRef.current) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
drag.current = {
|
||||
type,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
startCrop: { ...cropRef.current },
|
||||
}
|
||||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||
}, [])
|
||||
|
||||
const onPointerMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!drag.current || !containerRef.current) return
|
||||
const c = containerRef.current
|
||||
const cW = c.clientWidth
|
||||
const cH = c.clientHeight
|
||||
const dx = e.clientX - drag.current.startX
|
||||
const dy = e.clientY - drag.current.startY
|
||||
const sc = drag.current.startCrop
|
||||
const t = drag.current.type
|
||||
|
||||
let x = sc.x, y = sc.y, w = sc.w, h = sc.h
|
||||
|
||||
if (t === 'move') {
|
||||
x = clamp(sc.x + dx, 0, cW - sc.w)
|
||||
y = clamp(sc.y + dy, 0, cH - sc.h)
|
||||
} else {
|
||||
// Resize: width driven by dx, height derived from aspect ratio
|
||||
let newW: number
|
||||
if (t === 'br' || t === 'tr') newW = clamp(sc.w + dx, MIN_SIZE, cW)
|
||||
else newW = clamp(sc.w - dx, MIN_SIZE, cW)
|
||||
|
||||
const newH = newW / aspectRatio
|
||||
|
||||
if (t === 'br') { x = sc.x; y = sc.y }
|
||||
else if (t === 'bl') { x = sc.x + sc.w - newW; y = sc.y }
|
||||
else if (t === 'tr') { x = sc.x; y = sc.y + sc.h - newH }
|
||||
else { x = sc.x + sc.w - newW; y = sc.y + sc.h - newH }
|
||||
|
||||
x = clamp(x, 0, cW - newW)
|
||||
y = clamp(y, 0, cH - newH)
|
||||
w = newW
|
||||
h = newH
|
||||
}
|
||||
|
||||
setBox({ x, y, w, h })
|
||||
}, [aspectRatio, setBox])
|
||||
|
||||
const onPointerUp = useCallback(() => { drag.current = null }, [])
|
||||
|
||||
const handleCrop = useCallback(() => {
|
||||
const img = imgRef.current
|
||||
const c = containerRef.current
|
||||
const cb = cropRef.current
|
||||
if (!img || !c || !cb) return
|
||||
|
||||
const cW = c.clientWidth
|
||||
const cH = c.clientHeight
|
||||
const scale = Math.min(cW / img.naturalWidth, cH / img.naturalHeight)
|
||||
const dispW = img.naturalWidth * scale
|
||||
const dispH = img.naturalHeight * scale
|
||||
const offX = (cW - dispW) / 2
|
||||
const offY = (cH - dispH) / 2
|
||||
|
||||
// Map crop box back to source image coordinates
|
||||
const srcX = (cb.x - offX) / scale
|
||||
const srcY = (cb.y - offY) / scale
|
||||
const srcW = cb.w / scale
|
||||
const srcH = cb.h / scale
|
||||
|
||||
// Output resolution: screen size × device pixel ratio, capped at 1440px wide
|
||||
// Then scale down resolution until the result is under 3MB (keeping quality at 0.92)
|
||||
const MAX_BYTES = 1 * 1024 * 1024
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
let w = Math.min(Math.round(window.innerWidth * dpr), 1440)
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')!
|
||||
let dataUrl: string
|
||||
|
||||
do {
|
||||
const h = Math.round(w / aspectRatio)
|
||||
canvas.width = w
|
||||
canvas.height = h
|
||||
ctx.drawImage(img, srcX, srcY, srcW, srcH, 0, 0, w, h)
|
||||
dataUrl = canvas.toDataURL('image/jpeg', 0.92)
|
||||
// base64 → approx byte size
|
||||
const bytes = (dataUrl.length - dataUrl.indexOf(',') - 1) * 0.75
|
||||
if (bytes <= MAX_BYTES) break
|
||||
w = Math.round(w * 0.8)
|
||||
} while (w > 200)
|
||||
|
||||
onCrop(dataUrl!)
|
||||
}, [aspectRatio, onCrop])
|
||||
|
||||
return (
|
||||
<div className="cropper-overlay">
|
||||
<div className="cropper-header">
|
||||
<button type="button" className="cropper-cancel-btn" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
<span className="cropper-title">Crop Background</span>
|
||||
<button
|
||||
type="button"
|
||||
className="cropper-apply-btn"
|
||||
onClick={handleCrop}
|
||||
disabled={!cropBox}
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="cropper-container"
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerLeave={onPointerUp}
|
||||
>
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={imageSrc}
|
||||
className="cropper-image"
|
||||
onLoad={initCrop}
|
||||
alt=""
|
||||
draggable={false}
|
||||
/>
|
||||
|
||||
{cropBox && (
|
||||
<>
|
||||
{/* Darkened area outside crop box via box-shadow */}
|
||||
<div
|
||||
className="cropper-shade"
|
||||
style={{
|
||||
left: cropBox.x,
|
||||
top: cropBox.y,
|
||||
width: cropBox.w,
|
||||
height: cropBox.h,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Moveable crop box */}
|
||||
<div
|
||||
className="cropper-box"
|
||||
style={{
|
||||
left: cropBox.x,
|
||||
top: cropBox.y,
|
||||
width: cropBox.w,
|
||||
height: cropBox.h,
|
||||
}}
|
||||
onPointerDown={(e) => onPointerDown(e, 'move')}
|
||||
>
|
||||
{/* Rule-of-thirds grid */}
|
||||
<div className="cropper-grid" />
|
||||
|
||||
{/* Resize handles */}
|
||||
<div className="cropper-handle cropper-handle-tl" onPointerDown={(e) => onPointerDown(e, 'tl')} />
|
||||
<div className="cropper-handle cropper-handle-tr" onPointerDown={(e) => onPointerDown(e, 'tr')} />
|
||||
<div className="cropper-handle cropper-handle-bl" onPointerDown={(e) => onPointerDown(e, 'bl')} />
|
||||
<div className="cropper-handle cropper-handle-br" onPointerDown={(e) => onPointerDown(e, 'br')} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="cropper-hint">Drag to move · Drag corners to resize</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
274
src/components/ClockTimePicker.tsx
Normal file
274
src/components/ClockTimePicker.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||
|
||||
interface Props {
|
||||
value: string // "HH:MM" 24-hour format
|
||||
onChange: (value: string) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const SIZE = 240
|
||||
const CENTER = SIZE / 2
|
||||
const CLOCK_RADIUS = 108
|
||||
const NUM_RADIUS = 82
|
||||
const HAND_RADIUS = 74
|
||||
const TIP_RADIUS = 16
|
||||
|
||||
function polarToXY(angleDeg: number, radius: number) {
|
||||
const rad = ((angleDeg - 90) * Math.PI) / 180
|
||||
return {
|
||||
x: CENTER + radius * Math.cos(rad),
|
||||
y: CENTER + radius * Math.sin(rad),
|
||||
}
|
||||
}
|
||||
|
||||
function parseValue(v: string): { h: number; m: number } {
|
||||
const [h, m] = v.split(':').map(Number)
|
||||
return { h: isNaN(h) ? 8 : h, m: isNaN(m) ? 0 : m }
|
||||
}
|
||||
|
||||
export default function ClockTimePicker({ value, onChange, disabled }: Props) {
|
||||
const { h: initH, m: initM } = parseValue(value)
|
||||
|
||||
const [mode, setMode] = useState<'hours' | 'minutes'>('hours')
|
||||
const [hour24, setHour24] = useState(initH)
|
||||
const [minute, setMinute] = useState(initM)
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
const isDragging = useRef(false)
|
||||
// Keep mutable refs for use inside native event listeners
|
||||
const modeRef = useRef(mode)
|
||||
const isPMRef = useRef(initH >= 12)
|
||||
const hour24Ref = useRef(initH)
|
||||
const minuteRef = useRef(initM)
|
||||
|
||||
// Keep refs in sync with state
|
||||
useEffect(() => { modeRef.current = mode }, [mode])
|
||||
useEffect(() => { isPMRef.current = hour24 >= 12 }, [hour24])
|
||||
useEffect(() => { hour24Ref.current = hour24 }, [hour24])
|
||||
useEffect(() => { minuteRef.current = minute }, [minute])
|
||||
|
||||
// Sync when value prop changes externally
|
||||
useEffect(() => {
|
||||
const { h, m } = parseValue(value)
|
||||
setHour24(h)
|
||||
setMinute(m)
|
||||
}, [value])
|
||||
|
||||
const isPM = hour24 >= 12
|
||||
const hour12 = hour24 === 0 ? 12 : hour24 > 12 ? hour24 - 12 : hour24
|
||||
|
||||
const emit = useCallback(
|
||||
(h24: number, m: number) => {
|
||||
onChange(`${h24.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`)
|
||||
},
|
||||
[onChange]
|
||||
)
|
||||
|
||||
const handleAmPm = (pm: boolean) => {
|
||||
if (disabled) return
|
||||
let newH = hour24
|
||||
if (pm && hour24 < 12) newH = hour24 + 12
|
||||
else if (!pm && hour24 >= 12) newH = hour24 - 12
|
||||
setHour24(newH)
|
||||
emit(newH, minute)
|
||||
}
|
||||
|
||||
const applyAngle = useCallback(
|
||||
(angle: number, currentMode: 'hours' | 'minutes') => {
|
||||
if (currentMode === 'hours') {
|
||||
const h12 = Math.round(angle / 30) % 12 || 12
|
||||
const pm = isPMRef.current
|
||||
const newH24 = pm ? (h12 === 12 ? 12 : h12 + 12) : (h12 === 12 ? 0 : h12)
|
||||
setHour24(newH24)
|
||||
emit(newH24, minuteRef.current)
|
||||
} else {
|
||||
const m = Math.round(angle / 6) % 60
|
||||
setMinute(m)
|
||||
emit(hour24Ref.current, m)
|
||||
}
|
||||
},
|
||||
[emit]
|
||||
)
|
||||
|
||||
const getSVGAngle = (clientX: number, clientY: number): number => {
|
||||
if (!svgRef.current) return 0
|
||||
const rect = svgRef.current.getBoundingClientRect()
|
||||
const scale = rect.width / SIZE
|
||||
const x = clientX - rect.left - CENTER * scale
|
||||
const y = clientY - rect.top - CENTER * scale
|
||||
return ((Math.atan2(y, x) * 180) / Math.PI + 90 + 360) % 360
|
||||
}
|
||||
|
||||
// Mouse handlers (mouse events don't need passive:false)
|
||||
const handleMouseDown = (e: React.MouseEvent<SVGSVGElement>) => {
|
||||
if (disabled) return
|
||||
isDragging.current = true
|
||||
applyAngle(getSVGAngle(e.clientX, e.clientY), modeRef.current)
|
||||
}
|
||||
const handleMouseMove = (e: React.MouseEvent<SVGSVGElement>) => {
|
||||
if (!isDragging.current || disabled) return
|
||||
applyAngle(getSVGAngle(e.clientX, e.clientY), modeRef.current)
|
||||
}
|
||||
const handleMouseUp = (e: React.MouseEvent<SVGSVGElement>) => {
|
||||
if (!isDragging.current) return
|
||||
isDragging.current = false
|
||||
applyAngle(getSVGAngle(e.clientX, e.clientY), modeRef.current)
|
||||
if (modeRef.current === 'hours') setTimeout(() => setMode('minutes'), 120)
|
||||
}
|
||||
const handleMouseLeave = () => { isDragging.current = false }
|
||||
|
||||
// Attach non-passive touch listeners imperatively to avoid the passive warning
|
||||
useEffect(() => {
|
||||
const svg = svgRef.current
|
||||
if (!svg) return
|
||||
|
||||
const onTouchStart = (e: TouchEvent) => {
|
||||
if (disabled) return
|
||||
e.preventDefault()
|
||||
isDragging.current = true
|
||||
const t = e.touches[0]
|
||||
applyAngle(getSVGAngle(t.clientX, t.clientY), modeRef.current)
|
||||
}
|
||||
|
||||
const onTouchMove = (e: TouchEvent) => {
|
||||
if (!isDragging.current || disabled) return
|
||||
e.preventDefault()
|
||||
const t = e.touches[0]
|
||||
applyAngle(getSVGAngle(t.clientX, t.clientY), modeRef.current)
|
||||
}
|
||||
|
||||
const onTouchEnd = (e: TouchEvent) => {
|
||||
if (!isDragging.current) return
|
||||
e.preventDefault()
|
||||
isDragging.current = false
|
||||
const t = e.changedTouches[0]
|
||||
applyAngle(getSVGAngle(t.clientX, t.clientY), modeRef.current)
|
||||
if (modeRef.current === 'hours') setTimeout(() => setMode('minutes'), 120)
|
||||
}
|
||||
|
||||
svg.addEventListener('touchstart', onTouchStart, { passive: false })
|
||||
svg.addEventListener('touchmove', onTouchMove, { passive: false })
|
||||
svg.addEventListener('touchend', onTouchEnd, { passive: false })
|
||||
|
||||
return () => {
|
||||
svg.removeEventListener('touchstart', onTouchStart)
|
||||
svg.removeEventListener('touchmove', onTouchMove)
|
||||
svg.removeEventListener('touchend', onTouchEnd)
|
||||
}
|
||||
}, [applyAngle, disabled])
|
||||
|
||||
const handAngle = mode === 'hours' ? (hour12 / 12) * 360 : (minute / 60) * 360
|
||||
const handTip = polarToXY(handAngle, HAND_RADIUS)
|
||||
const displayH = hour12.toString()
|
||||
const displayM = minute.toString().padStart(2, '0')
|
||||
const selectedNum = mode === 'hours' ? hour12 : minute
|
||||
|
||||
const hourPositions = Array.from({ length: 12 }, (_, i) => {
|
||||
const h = i + 1
|
||||
return { h, ...polarToXY((h / 12) * 360, NUM_RADIUS) }
|
||||
})
|
||||
|
||||
const minutePositions = Array.from({ length: 12 }, (_, i) => {
|
||||
const m = i * 5
|
||||
return { m, ...polarToXY((m / 60) * 360, NUM_RADIUS) }
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="clock-picker">
|
||||
{/* Time display */}
|
||||
<div className="clock-picker__display">
|
||||
<button
|
||||
type="button"
|
||||
className={`clock-picker__seg${mode === 'hours' ? ' clock-picker__seg--active' : ''}`}
|
||||
onClick={() => !disabled && setMode('hours')}
|
||||
>
|
||||
{displayH}
|
||||
</button>
|
||||
<span className="clock-picker__colon">:</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`clock-picker__seg${mode === 'minutes' ? ' clock-picker__seg--active' : ''}`}
|
||||
onClick={() => !disabled && setMode('minutes')}
|
||||
>
|
||||
{displayM}
|
||||
</button>
|
||||
<div className="clock-picker__ampm">
|
||||
<button
|
||||
type="button"
|
||||
className={`clock-picker__ampm-btn${!isPM ? ' clock-picker__ampm-btn--active' : ''}`}
|
||||
onClick={() => handleAmPm(false)}
|
||||
disabled={disabled}
|
||||
>AM</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`clock-picker__ampm-btn${isPM ? ' clock-picker__ampm-btn--active' : ''}`}
|
||||
onClick={() => handleAmPm(true)}
|
||||
disabled={disabled}
|
||||
>PM</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Clock face */}
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`0 0 ${SIZE} ${SIZE}`}
|
||||
className="clock-picker__face"
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
style={{ cursor: disabled ? 'default' : 'pointer', touchAction: 'none', userSelect: 'none' }}
|
||||
>
|
||||
<circle cx={CENTER} cy={CENTER} r={CLOCK_RADIUS} className="clock-picker__bg" />
|
||||
|
||||
{/* Shaded sector */}
|
||||
{(() => {
|
||||
const start = polarToXY(0, HAND_RADIUS)
|
||||
const end = polarToXY(handAngle, HAND_RADIUS)
|
||||
const large = handAngle > 180 ? 1 : 0
|
||||
return (
|
||||
<path
|
||||
d={`M ${CENTER} ${CENTER} L ${start.x} ${start.y} A ${HAND_RADIUS} ${HAND_RADIUS} 0 ${large} 1 ${end.x} ${end.y} Z`}
|
||||
className="clock-picker__sector"
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
|
||||
<line x1={CENTER} y1={CENTER} x2={handTip.x} y2={handTip.y} className="clock-picker__hand" />
|
||||
<circle cx={CENTER} cy={CENTER} r={4} className="clock-picker__center-dot" />
|
||||
<circle cx={handTip.x} cy={handTip.y} r={TIP_RADIUS} className="clock-picker__hand-tip" />
|
||||
|
||||
{mode === 'hours' && hourPositions.map(({ h, x, y }) => (
|
||||
<text key={h} x={x} y={y} textAnchor="middle" dominantBaseline="central"
|
||||
className={`clock-picker__num${h === selectedNum ? ' clock-picker__num--selected' : ''}`}
|
||||
>{h}</text>
|
||||
))}
|
||||
|
||||
{mode === 'minutes' && minutePositions.map(({ m, x, y }) => (
|
||||
<text key={m} x={x} y={y} textAnchor="middle" dominantBaseline="central"
|
||||
className={`clock-picker__num${m === selectedNum ? ' clock-picker__num--selected' : ''}`}
|
||||
>{m.toString().padStart(2, '0')}</text>
|
||||
))}
|
||||
|
||||
{mode === 'minutes' && Array.from({ length: 60 }, (_, i) => {
|
||||
if (i % 5 === 0) return null
|
||||
const angle = (i / 60) * 360
|
||||
const inner = polarToXY(angle, CLOCK_RADIUS - 10)
|
||||
const outer = polarToXY(angle, CLOCK_RADIUS - 4)
|
||||
return <line key={i} x1={inner.x} y1={inner.y} x2={outer.x} y2={outer.y} className="clock-picker__tick" />
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* Mode pills */}
|
||||
<div className="clock-picker__modes">
|
||||
<button type="button"
|
||||
className={`clock-picker__mode-btn${mode === 'hours' ? ' clock-picker__mode-btn--active' : ''}`}
|
||||
onClick={() => !disabled && setMode('hours')}
|
||||
>Hours</button>
|
||||
<button type="button"
|
||||
className={`clock-picker__mode-btn${mode === 'minutes' ? ' clock-picker__mode-btn--active' : ''}`}
|
||||
onClick={() => !disabled && setMode('minutes')}
|
||||
>Minutes</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
export function PageLoader() {
|
||||
export function PageLoader({ transparent }: { transparent?: boolean }) {
|
||||
return (
|
||||
<div className="page-loader" role="status" aria-label="Loading">
|
||||
<div className={`page-loader${transparent ? ' page-loader--transparent' : ''}`} role="status" aria-label="Loading">
|
||||
<svg
|
||||
className="page-loader__tree"
|
||||
viewBox="0 0 60 90"
|
||||
|
||||
@@ -1,23 +1,45 @@
|
||||
import { type ReactNode } from 'react'
|
||||
import { type ReactNode, Suspense, useState, useEffect } from 'react'
|
||||
import { Navigate, useLocation } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { PageLoader } from './PageLoader'
|
||||
|
||||
type Props = {
|
||||
children: ReactNode
|
||||
// Mounts only once Suspense has resolved (chunk is ready).
|
||||
// Signals the parent to hide the loader and reveal content.
|
||||
function ContentReady({ onReady }: { onReady: () => void }) {
|
||||
useEffect(() => {
|
||||
onReady()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
return null
|
||||
}
|
||||
|
||||
type Props = { children: ReactNode }
|
||||
|
||||
export function ProtectedRoute({ children }: Props) {
|
||||
const { user, loading } = useAuth()
|
||||
const location = useLocation()
|
||||
|
||||
if (loading) {
|
||||
return <PageLoader />
|
||||
}
|
||||
// On page refresh: loading starts true → contentReady=false → loader shows throughout.
|
||||
// On in-app navigation: loading is already false → contentReady=true → no loader shown.
|
||||
const [contentReady, setContentReady] = useState(() => !loading)
|
||||
|
||||
if (!user) {
|
||||
if (!loading && !user) {
|
||||
return <Navigate to="/" state={{ from: location }} replace />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
const showLoader = loading || !contentReady
|
||||
|
||||
return (
|
||||
<>
|
||||
{showLoader && <PageLoader />}
|
||||
{!loading && user && (
|
||||
<div style={{ display: contentReady ? 'contents' : 'none' }}>
|
||||
<Suspense fallback={null}>
|
||||
<ContentReady onReady={() => setContentReady(true)} />
|
||||
{children}
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
saveEncryptedSecretKey,
|
||||
getEncryptedSecretKey,
|
||||
} from '../lib/crypto'
|
||||
import { REMINDER_TIME_KEY, REMINDER_ENABLED_KEY } from '../hooks/useReminder'
|
||||
|
||||
type MongoUser = {
|
||||
id: string
|
||||
@@ -38,6 +39,13 @@ type MongoUser = {
|
||||
photoURL?: string
|
||||
theme?: string
|
||||
tutorial?: boolean
|
||||
backgroundImage?: string | null
|
||||
backgroundImages?: string[]
|
||||
reminder?: {
|
||||
enabled: boolean
|
||||
time?: string
|
||||
timezone?: string
|
||||
}
|
||||
}
|
||||
|
||||
type AuthContextValue = {
|
||||
@@ -62,6 +70,21 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [authError, setAuthError] = useState<string | null>(null)
|
||||
|
||||
// Apply custom background image whenever mongoUser changes
|
||||
useEffect(() => {
|
||||
const bg = mongoUser?.backgroundImage
|
||||
if (bg) {
|
||||
document.body.style.backgroundImage = `url(${bg})`
|
||||
document.body.style.backgroundSize = 'cover'
|
||||
document.body.style.backgroundPosition = 'center'
|
||||
document.body.style.backgroundAttachment = 'fixed'
|
||||
document.body.classList.add('gj-has-bg')
|
||||
} else {
|
||||
document.body.style.backgroundImage = ''
|
||||
document.body.classList.remove('gj-has-bg')
|
||||
}
|
||||
}, [mongoUser?.backgroundImage])
|
||||
|
||||
// Initialize encryption keys on login
|
||||
async function initializeEncryption(authUser: User) {
|
||||
try {
|
||||
@@ -118,6 +141,18 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}
|
||||
|
||||
function syncReminderFromDb(mongoUser: MongoUser) {
|
||||
const r = mongoUser.reminder
|
||||
if (r) {
|
||||
localStorage.setItem(REMINDER_ENABLED_KEY, r.enabled ? 'true' : 'false')
|
||||
if (r.time) localStorage.setItem(REMINDER_TIME_KEY, r.time)
|
||||
else localStorage.removeItem(REMINDER_TIME_KEY)
|
||||
} else {
|
||||
localStorage.setItem(REMINDER_ENABLED_KEY, 'false')
|
||||
localStorage.removeItem(REMINDER_TIME_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
// Register or fetch user from MongoDB
|
||||
async function syncUserWithDatabase(authUser: User) {
|
||||
try {
|
||||
@@ -131,12 +166,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
try {
|
||||
console.log('[Auth] Fetching user by email:', email)
|
||||
const existingUser = await getUserByEmail(email, token) as MongoUser
|
||||
// console.log('[Auth] Found existing user:', existingUser.id)
|
||||
setUserId(existingUser.id)
|
||||
setMongoUser(existingUser)
|
||||
syncReminderFromDb(existingUser)
|
||||
} catch (error) {
|
||||
console.warn('[Auth] User not found, registering...', error)
|
||||
// User doesn't exist, register them
|
||||
const newUser = await registerUser(
|
||||
{
|
||||
email,
|
||||
@@ -148,6 +182,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
console.log('[Auth] Registered new user:', newUser.id)
|
||||
setUserId(newUser.id)
|
||||
setMongoUser(newUser)
|
||||
syncReminderFromDb(newUser)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Auth] Error syncing user with database:', error)
|
||||
@@ -209,13 +244,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
// Clear secret key from memory
|
||||
setSecretKey(null)
|
||||
setMongoUser(null)
|
||||
// Clear pending tour step (session state)
|
||||
localStorage.removeItem('gj-tour-pending-step')
|
||||
// Keep device key and encrypted key for next login
|
||||
// Do NOT clear localStorage or IndexedDB
|
||||
localStorage.removeItem(REMINDER_TIME_KEY)
|
||||
localStorage.removeItem(REMINDER_ENABLED_KEY)
|
||||
await firebaseSignOut(auth)
|
||||
setUserId(null)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
134
src/hooks/useReminder.ts
Normal file
134
src/hooks/useReminder.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* 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 existing sw.js (which includes Firebase messaging). */
|
||||
async function getFcmToken(): Promise<string | null> {
|
||||
const messaging = await messagingPromise
|
||||
if (!messaging) {
|
||||
console.warn('[FCM] Firebase Messaging not supported in this browser')
|
||||
return null
|
||||
}
|
||||
|
||||
const swReg = await navigator.serviceWorker.ready
|
||||
console.log('[FCM] Service worker ready:', swReg.active?.scriptURL)
|
||||
|
||||
const token = await getToken(messaging, { vapidKey: VAPID_KEY, serviceWorkerRegistration: swReg })
|
||||
if (token) {
|
||||
console.log('[FCM] Token obtained:', token.slice(0, 20) + '…')
|
||||
} else {
|
||||
console.warn('[FCM] getToken returned empty — VAPID key wrong or SW not registered?')
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
console.log('[FCM] Saving token and reminder settings:', { timeStr, timezone })
|
||||
|
||||
await saveFcmToken(userId, fcmToken, authToken)
|
||||
console.log('[FCM] Token saved to backend')
|
||||
|
||||
await saveReminderSettings(userId, { time: timeStr, enabled: true, timezone }, authToken)
|
||||
console.log('[FCM] Reminder settings saved to backend')
|
||||
|
||||
localStorage.setItem(REMINDER_TIME_KEY, timeStr)
|
||||
localStorage.setItem(REMINDER_ENABLED_KEY, 'true')
|
||||
return null
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
console.error('[FCM] Reminder setup failed:', msg)
|
||||
return `Failed to set up reminder: ${msg}`
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 () => {}
|
||||
|
||||
console.log('[FCM] Foreground message listener registered')
|
||||
|
||||
const unsubscribe = onMessage(messaging, (payload) => {
|
||||
console.log('[FCM] Foreground message received:', payload)
|
||||
const title = payload.notification?.title || 'Grateful Journal 🌱'
|
||||
const body = payload.notification?.body || "You haven't written today yet."
|
||||
if (Notification.permission !== 'granted') {
|
||||
console.warn('[FCM] Notification permission not granted — cannot show notification')
|
||||
return
|
||||
}
|
||||
new Notification(title, {
|
||||
body,
|
||||
icon: '/web-app-manifest-192x192.png',
|
||||
tag: 'gj-daily-reminder',
|
||||
})
|
||||
})
|
||||
|
||||
return unsubscribe
|
||||
}
|
||||
83
src/hooks/useSwipeNav.ts
Normal file
83
src/hooks/useSwipeNav.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
|
||||
const PAGES = ['/write', '/history', '/settings']
|
||||
const SWIPE_THRESHOLD = 55 // minimum horizontal px to count as a swipe
|
||||
const DESKTOP_BREAKPOINT = 860
|
||||
|
||||
/** Walk up the DOM and return true if any ancestor is horizontally scrollable */
|
||||
function isInHScrollable(el: Element | null): boolean {
|
||||
while (el && el !== document.body) {
|
||||
const style = window.getComputedStyle(el)
|
||||
const ox = style.overflowX
|
||||
if ((ox === 'scroll' || ox === 'auto') && el.scrollWidth > el.clientWidth) {
|
||||
return true
|
||||
}
|
||||
el = el.parentElement
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Swipe left/right to navigate between the three main pages (mobile only) */
|
||||
export function useSwipeNav() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
useEffect(() => {
|
||||
let startX = 0
|
||||
let startY = 0
|
||||
let startTarget: Element | null = null
|
||||
let cancelled = false
|
||||
|
||||
const onTouchStart = (e: TouchEvent) => {
|
||||
startX = e.touches[0].clientX
|
||||
startY = e.touches[0].clientY
|
||||
startTarget = e.target as Element
|
||||
cancelled = false
|
||||
}
|
||||
|
||||
const onTouchMove = (e: TouchEvent) => {
|
||||
// If vertical movement dominates early, cancel the swipe so we never
|
||||
// accidentally navigate while the user is scrolling.
|
||||
const dx = Math.abs(e.touches[0].clientX - startX)
|
||||
const dy = Math.abs(e.touches[0].clientY - startY)
|
||||
if (!cancelled && dy > dx && dy > 10) cancelled = true
|
||||
}
|
||||
|
||||
const onTouchEnd = (e: TouchEvent) => {
|
||||
if (cancelled) return
|
||||
if (window.innerWidth >= DESKTOP_BREAKPOINT) return
|
||||
|
||||
const dx = e.changedTouches[0].clientX - startX
|
||||
const dy = e.changedTouches[0].clientY - startY
|
||||
|
||||
// Must be predominantly horizontal
|
||||
if (Math.abs(dx) <= Math.abs(dy)) return
|
||||
// Must clear the distance threshold
|
||||
if (Math.abs(dx) < SWIPE_THRESHOLD) return
|
||||
// Don't swipe-navigate when inside a horizontal scroll container
|
||||
if (isInHScrollable(startTarget)) return
|
||||
// Don't swipe-navigate when a modal/overlay is open
|
||||
if (document.querySelector('.confirm-modal-overlay, .cropper-overlay, .reminder-modal-overlay')) return
|
||||
|
||||
const idx = PAGES.indexOf(location.pathname)
|
||||
if (idx === -1) return
|
||||
|
||||
if (dx < 0 && idx < PAGES.length - 1) {
|
||||
navigate(PAGES[idx + 1]) // swipe left → next page
|
||||
} else if (dx > 0 && idx > 0) {
|
||||
navigate(PAGES[idx - 1]) // swipe right → previous page
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('touchstart', onTouchStart, { passive: true })
|
||||
document.addEventListener('touchmove', onTouchMove, { passive: true })
|
||||
document.addEventListener('touchend', onTouchEnd, { passive: true })
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('touchstart', onTouchStart)
|
||||
document.removeEventListener('touchmove', onTouchMove)
|
||||
document.removeEventListener('touchend', onTouchEnd)
|
||||
}
|
||||
}, [navigate, location.pathname])
|
||||
}
|
||||
@@ -24,7 +24,8 @@ input, textarea {
|
||||
--color-primary: #22c55e;
|
||||
--color-primary-hover: #16a34a;
|
||||
--color-bg-soft: #eef6ee;
|
||||
--color-surface: #ffffff;
|
||||
--card-bg-opacity: 0.7;
|
||||
--color-surface: rgb(255 255 255 / var(--card-bg-opacity));
|
||||
--color-accent-light: #dcfce7;
|
||||
--color-text: #1a1a1a;
|
||||
--color-text-muted: #6b7280;
|
||||
@@ -81,7 +82,7 @@ button:focus-visible {
|
||||
--color-primary: #4ade80;
|
||||
--color-primary-hover: #22c55e;
|
||||
--color-bg-soft: #0f0f0f;
|
||||
--color-surface: #1a1a1a;
|
||||
--color-surface: rgb(26 26 26 / var(--card-bg-opacity));
|
||||
--color-accent-light: rgba(74, 222, 128, 0.12);
|
||||
--color-text: #e8f5e8;
|
||||
--color-text-muted: #7a8a7a;
|
||||
@@ -95,3 +96,28 @@ button:focus-visible {
|
||||
[data-theme="dark"] body {
|
||||
background: #0a0a0a;
|
||||
}
|
||||
|
||||
/* ── Liquid Glass theme root overrides ───────────────────── */
|
||||
[data-theme="liquid-glass"] {
|
||||
--glass-bg: rgba(255, 255, 255, 0.18);
|
||||
--glass-blur: blur(28px) saturate(200%) brightness(1.05);
|
||||
--glass-border: 1px solid rgba(255, 255, 255, 0.55);
|
||||
--glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.12), 0 1px 0 rgba(255, 255, 255, 0.7) inset;
|
||||
--color-primary: #16a34a;
|
||||
--color-primary-hover: #15803d;
|
||||
--color-bg-soft: transparent;
|
||||
--color-surface: var(--glass-bg);
|
||||
--color-accent-light: rgba(220, 252, 231, 0.4);
|
||||
--color-text: #0f172a;
|
||||
--color-text-muted: #334155;
|
||||
--color-border: rgba(255, 255, 255, 0.4);
|
||||
|
||||
color: var(--color-text);
|
||||
background-color: transparent;
|
||||
caret-color: #16a34a;
|
||||
}
|
||||
|
||||
/* Same bg as light theme when no custom image is set */
|
||||
[data-theme="liquid-glass"] body:not(.gj-has-bg) {
|
||||
background: #eef6ee;
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ export async function getUserByEmail(email: string, token: string) {
|
||||
|
||||
export async function updateUserProfile(
|
||||
userId: string,
|
||||
updates: { displayName?: string; photoURL?: string; theme?: string; tutorial?: boolean },
|
||||
updates: { displayName?: string; photoURL?: string; theme?: string; tutorial?: boolean; backgroundImage?: string | null; backgroundImages?: string[] },
|
||||
token: string
|
||||
) {
|
||||
return apiCall(`/users/${userId}`, {
|
||||
|
||||
@@ -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))
|
||||
|
||||
10
src/main.tsx
10
src/main.tsx
@@ -2,6 +2,11 @@ import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { listenForegroundMessages } from './hooks/useReminder'
|
||||
|
||||
// Apply saved theme immediately to avoid flash
|
||||
const savedTheme = localStorage.getItem('gj-theme') || 'light'
|
||||
document.documentElement.setAttribute('data-theme', savedTheme)
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
@@ -9,6 +14,11 @@ if ('serviceWorker' in navigator) {
|
||||
})
|
||||
}
|
||||
|
||||
// Show FCM notifications when app is open in foreground
|
||||
listenForegroundMessages().catch((err) => {
|
||||
console.error('[FCM] Failed to set up foreground message listener:', err)
|
||||
})
|
||||
|
||||
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 | Private, Encrypted Gratitude Journaling',
|
||||
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 | Private, Encrypted Gratitude Journaling',
|
||||
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">
|
||||
@@ -22,7 +30,7 @@ export default function AboutPage() {
|
||||
|
||||
<h2>Features</h2>
|
||||
<ul>
|
||||
<li><strong>End-to-end encrypted</strong> — your entries are encrypted before leaving your device. We cannot read them.</li>
|
||||
<li><strong>End-to-end encrypted entries</strong> — your journal content is encrypted before leaving your device. We cannot read it.</li>
|
||||
<li><strong>No ads, no tracking</strong> — we don't sell your data or show you ads.</li>
|
||||
<li><strong>Works offline</strong> — installable as a PWA on Android, iOS, and desktop.</li>
|
||||
<li><strong>Daily prompts</strong> — gentle nudges to keep your practice consistent.</li>
|
||||
@@ -40,7 +48,10 @@ export default function AboutPage() {
|
||||
<h2>Privacy first</h2>
|
||||
<p>
|
||||
We built Grateful Journal because we believe your inner thoughts deserve a private space.
|
||||
Read our full <Link to="/privacy">Privacy Policy</Link> to understand exactly how your data is protected.
|
||||
Your journal entries are end-to-end encrypted — only you can read them. App preferences
|
||||
such as your display name, profile photo, and background images are stored as plain account
|
||||
settings and are not encrypted. Read our full <Link to="/privacy">Privacy Policy</Link> for
|
||||
a complete breakdown of what is and isn't encrypted.
|
||||
</p>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { getUserEntries, deleteEntry, type JournalEntry } from '../lib/api'
|
||||
import { decryptEntry } from '../lib/crypto'
|
||||
import { getUserEntries, deleteEntry, updateEntry, type JournalEntry } from '../lib/api'
|
||||
import { decryptEntry, encryptEntry } from '../lib/crypto'
|
||||
import { formatIST, getISTDateComponents } from '../lib/timezone'
|
||||
import BottomNav from '../components/BottomNav'
|
||||
import { useOnboardingTour, hasPendingTourStep, clearPendingTourStep } from '../hooks/useOnboardingTour'
|
||||
@@ -22,6 +22,10 @@ export default function HistoryPage() {
|
||||
const [selectedEntry, setSelectedEntry] = useState<DecryptedEntry | null>(null)
|
||||
const [entryToDelete, setEntryToDelete] = useState<DecryptedEntry | null>(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [entryToEdit, setEntryToEdit] = useState<DecryptedEntry | null>(null)
|
||||
const [editTitle, setEditTitle] = useState('')
|
||||
const [editContent, setEditContent] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const { continueTourOnHistory } = useOnboardingTour()
|
||||
|
||||
@@ -178,6 +182,58 @@ export default function HistoryPage() {
|
||||
setSelectedDate(new Date(currentMonth.getFullYear(), currentMonth.getMonth(), day))
|
||||
}
|
||||
|
||||
const isEntryFromToday = (createdAt: string): boolean => {
|
||||
const nowIST = new Date(new Date().getTime() + 5.5 * 60 * 60 * 1000)
|
||||
const components = getISTDateComponents(createdAt)
|
||||
return (
|
||||
components.year === nowIST.getUTCFullYear() &&
|
||||
components.month === nowIST.getUTCMonth() &&
|
||||
components.date === nowIST.getUTCDate()
|
||||
)
|
||||
}
|
||||
|
||||
const openEditModal = (entry: DecryptedEntry) => {
|
||||
setEntryToEdit(entry)
|
||||
setEditTitle(entry.decryptedTitle || '')
|
||||
setEditContent(entry.decryptedContent || '')
|
||||
}
|
||||
|
||||
const handleEditSave = async () => {
|
||||
if (!entryToEdit || !user || !userId || !secretKey) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const token = await user.getIdToken()
|
||||
const combined = `${editTitle.trim()}\n\n${editContent.trim()}`
|
||||
const { ciphertext, nonce } = await encryptEntry(combined, secretKey)
|
||||
|
||||
await updateEntry(userId, entryToEdit.id, {
|
||||
title: undefined,
|
||||
content: undefined,
|
||||
encryption: {
|
||||
encrypted: true,
|
||||
ciphertext,
|
||||
nonce,
|
||||
algorithm: 'XSalsa20-Poly1305',
|
||||
},
|
||||
}, token)
|
||||
|
||||
const updatedEntry: DecryptedEntry = {
|
||||
...entryToEdit,
|
||||
encryption: { encrypted: true, ciphertext, nonce, algorithm: 'XSalsa20-Poly1305' },
|
||||
decryptedTitle: editTitle.trim(),
|
||||
decryptedContent: editContent.trim(),
|
||||
}
|
||||
|
||||
setEntries((prev) => prev.map((e) => e.id === entryToEdit.id ? updatedEntry : e))
|
||||
if (selectedEntry?.id === entryToEdit.id) setSelectedEntry(updatedEntry)
|
||||
setEntryToEdit(null)
|
||||
} catch (error) {
|
||||
console.error('Failed to update entry:', error)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!entryToDelete || !user || !userId) return
|
||||
setDeleting(true)
|
||||
@@ -270,7 +326,7 @@ export default function HistoryPage() {
|
||||
</h3>
|
||||
|
||||
{loadingEntries ? (
|
||||
<PageLoader />
|
||||
<PageLoader transparent />
|
||||
) : (
|
||||
<div className="entries-list">
|
||||
{selectedDateEntries.length === 0 ? (
|
||||
@@ -291,6 +347,19 @@ export default function HistoryPage() {
|
||||
<span className="entry-date">{formatDate(entry.createdAt)}</span>
|
||||
<div className="entry-header-right">
|
||||
<span className="entry-time">{formatTime(entry.createdAt)}</span>
|
||||
{isEntryFromToday(entry.createdAt) && (
|
||||
<button
|
||||
type="button"
|
||||
className="entry-edit-btn"
|
||||
title="Edit entry"
|
||||
onClick={(e) => { e.stopPropagation(); openEditModal(entry) }}
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="entry-delete-btn"
|
||||
@@ -332,6 +401,20 @@ export default function HistoryPage() {
|
||||
<span className="entry-modal-date">{formatDate(selectedEntry.createdAt)}</span>
|
||||
<span className="entry-modal-time">{formatTime(selectedEntry.createdAt)}</span>
|
||||
</div>
|
||||
<div className="entry-modal-actions">
|
||||
{isEntryFromToday(selectedEntry.createdAt) && (
|
||||
<button
|
||||
type="button"
|
||||
className="entry-modal-edit"
|
||||
onClick={() => { setSelectedEntry(null); openEditModal(selectedEntry) }}
|
||||
title="Edit entry"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="entry-modal-close"
|
||||
@@ -344,6 +427,7 @@ export default function HistoryPage() {
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="entry-modal-title">
|
||||
{selectedEntry.decryptedTitle || selectedEntry.title || '[Untitled]'}
|
||||
@@ -381,6 +465,71 @@ export default function HistoryPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Entry Modal */}
|
||||
{entryToEdit && (
|
||||
<div
|
||||
className="entry-modal-overlay"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget && !saving) setEntryToEdit(null)
|
||||
}}
|
||||
>
|
||||
<div className="entry-modal edit-entry-modal">
|
||||
<div className="entry-modal-header">
|
||||
<span className="entry-modal-date">Edit Entry</span>
|
||||
<button
|
||||
type="button"
|
||||
className="entry-modal-close"
|
||||
onClick={() => setEntryToEdit(null)}
|
||||
disabled={saving}
|
||||
title="Close"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="edit-entry-fields">
|
||||
<input
|
||||
className="edit-entry-title-input"
|
||||
type="text"
|
||||
placeholder="Title"
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
disabled={saving}
|
||||
maxLength={200}
|
||||
/>
|
||||
<textarea
|
||||
className="edit-entry-content-input"
|
||||
placeholder="What are you grateful for today?"
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
disabled={saving}
|
||||
rows={8}
|
||||
/>
|
||||
</div>
|
||||
<div className="edit-entry-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="delete-confirm-cancel"
|
||||
onClick={() => setEntryToEdit(null)}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="edit-entry-save"
|
||||
onClick={handleEditSave}
|
||||
disabled={saving || (!editTitle.trim() && !editContent.trim())}
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{entryToDelete && (
|
||||
<div
|
||||
|
||||
@@ -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: 'Private Gratitude Journal App | Grateful 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)
|
||||
@@ -28,7 +34,10 @@ export default function LoginPage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (loading || signingIn) {
|
||||
// Keep showing the loader until the navigate effect fires.
|
||||
// Without the `user` check here, the login form flashes for one frame
|
||||
// between loading→false and the useEffect redirect.
|
||||
if (loading || signingIn || user) {
|
||||
return <PageLoader />
|
||||
}
|
||||
|
||||
|
||||
@@ -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. App preferences like background images are stored unencrypted. No ads, no tracking, no data selling.',
|
||||
})
|
||||
return (
|
||||
<div className="static-page">
|
||||
<header className="static-page__header">
|
||||
@@ -9,7 +17,7 @@ export default function PrivacyPage() {
|
||||
|
||||
<main className="static-page__content">
|
||||
<h1>Privacy Policy</h1>
|
||||
<p className="static-page__updated">Last updated: April 8, 2026</p>
|
||||
<p className="static-page__updated">Last updated: April 14, 2026</p>
|
||||
|
||||
<p>
|
||||
Grateful Journal is built on a simple promise: your journal entries are yours alone.
|
||||
@@ -20,13 +28,21 @@ export default function PrivacyPage() {
|
||||
<ul>
|
||||
<li><strong>Account info</strong> — your name and email address via Google Sign-In, used solely to identify your account.</li>
|
||||
<li><strong>Journal entries</strong> — stored encrypted in our database. We do not have access to the content of your entries.</li>
|
||||
<li><strong>App preferences</strong> — your display name, profile photo, background images, and theme are stored unencrypted as account settings. See the Encryption section below for the full breakdown.</li>
|
||||
<li><strong>Usage data</strong> — no analytics, no tracking pixels, no third-party advertising SDKs.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Encryption</h2>
|
||||
<p>
|
||||
Your journal entries are end-to-end encrypted. They are encrypted on your device before being sent to our servers.
|
||||
We store only the encrypted ciphertext — decryption happens locally in your browser using your account key.
|
||||
Encryption is applied selectively based on the sensitivity of each type of data:
|
||||
</p>
|
||||
<ul>
|
||||
<li><strong>Journal entries — end-to-end encrypted.</strong> Entries are encrypted on your device using XSalsa20-Poly1305 before being sent to our servers. We store only ciphertext. Decryption happens locally in your browser using a key derived from your account. We cannot read your entries.</li>
|
||||
<li><strong>App preferences — not encrypted.</strong> Your display name, profile photo, background images, and theme setting are stored as plain data. These are appearance and account settings, not personal journal content. They are accessible to us at the database level.</li>
|
||||
</ul>
|
||||
<p>
|
||||
If you upload a personal photo as a background image, be aware that it is stored unencrypted on our servers.
|
||||
For maximum privacy, use abstract or non-personal images as backgrounds.
|
||||
</p>
|
||||
|
||||
<h2>Data sharing</h2>
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { deleteUser as deleteUserApi, updateUserProfile } from '../lib/api'
|
||||
import { BgImageCropper } from '../components/BgImageCropper'
|
||||
import { clearDeviceKey, clearEncryptedSecretKey } from '../lib/crypto'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
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,
|
||||
} from '../hooks/useReminder'
|
||||
import ClockTimePicker from '../components/ClockTimePicker'
|
||||
|
||||
const MAX_PHOTO_SIZE = 200 // px — resize to 200x200
|
||||
const MAX_BG_HISTORY = 3
|
||||
const MAX_BG_IMAGE_BYTES = 1 * 1024 * 1024 // 1 MB per image
|
||||
const MAX_BG_PAYLOAD_BYTES = MAX_BG_HISTORY * MAX_BG_IMAGE_BYTES // 9 MB total
|
||||
|
||||
/** Approximate decoded byte size of a base64 data URL */
|
||||
function dataUrlBytes(dataUrl: string): number {
|
||||
const base64 = dataUrl.slice(dataUrl.indexOf(',') + 1)
|
||||
return Math.round(base64.length * 0.75)
|
||||
}
|
||||
|
||||
function resizeImage(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -39,8 +54,8 @@ export default function SettingsPage() {
|
||||
const { user, userId, mongoUser, signOut, loading, refreshMongoUser } = useAuth()
|
||||
// const [passcodeEnabled, setPasscodeEnabled] = useState(false) // Passcode lock — disabled for now
|
||||
// const [faceIdEnabled, setFaceIdEnabled] = useState(false) // Face ID — disabled for now
|
||||
const [theme, setTheme] = useState<'light' | 'dark'>(() => {
|
||||
return (localStorage.getItem('gj-theme') as 'light' | 'dark') || 'light'
|
||||
const [theme, setTheme] = useState<'light' | 'dark' | 'liquid-glass'>(() => {
|
||||
return (localStorage.getItem('gj-theme') as 'light' | 'dark' | 'liquid-glass') || 'light'
|
||||
})
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
||||
|
||||
@@ -55,12 +70,32 @@ 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('')
|
||||
const [editPhotoPreview, setEditPhotoPreview] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// Background image state
|
||||
const bgFileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [showBgModal, setShowBgModal] = useState(false)
|
||||
const [cropperSrc, setCropperSrc] = useState<string | null>(null)
|
||||
const [bgApplying, setBgApplying] = useState(false)
|
||||
|
||||
// Derived from mongoUser (no local state — always fresh after refreshMongoUser)
|
||||
const bgImages: string[] = (mongoUser as { backgroundImages?: string[] } | null)?.backgroundImages ?? []
|
||||
const activeImage: string | null = mongoUser?.backgroundImage ?? null
|
||||
// Tile aspect ratio matches the actual screen so previews reflect real proportions
|
||||
const screenAspect = `${window.innerWidth} / ${window.innerHeight}`
|
||||
|
||||
// Continue onboarding tour if navigated here from the history page tour
|
||||
useEffect(() => {
|
||||
if (hasPendingTourStep() === 'settings') {
|
||||
@@ -124,8 +159,77 @@ export default function SettingsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function bgUpdate(updates: Parameters<typeof updateUserProfile>[1]) {
|
||||
if (!user || !userId) return
|
||||
setBgApplying(true)
|
||||
try {
|
||||
const token = await user.getIdToken()
|
||||
await updateUserProfile(userId, updates, token)
|
||||
await refreshMongoUser()
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : 'Failed to update background'
|
||||
setMessage({ type: 'error', text: msg })
|
||||
} finally {
|
||||
setBgApplying(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleApplyDefault = () => {
|
||||
if (!activeImage) return // already on default
|
||||
bgUpdate({ backgroundImage: null })
|
||||
}
|
||||
|
||||
const handleApplyFromGallery = (img: string) => {
|
||||
if (img === activeImage) return // already active
|
||||
bgUpdate({ backgroundImage: img })
|
||||
}
|
||||
|
||||
const handleDeleteBgImage = (img: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
const newHistory = bgImages.filter(i => i !== img)
|
||||
// If the deleted image was active, clear it too
|
||||
const updates: Parameters<typeof updateUserProfile>[1] = { backgroundImages: newHistory }
|
||||
if (img === activeImage) updates.backgroundImage = null
|
||||
bgUpdate(updates)
|
||||
}
|
||||
|
||||
const handleBgFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setShowBgModal(false)
|
||||
setCropperSrc(URL.createObjectURL(file))
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const handleCropDone = async (dataUrl: string) => {
|
||||
if (cropperSrc) URL.revokeObjectURL(cropperSrc)
|
||||
setCropperSrc(null)
|
||||
|
||||
// Guard: individual image must be within limit (cropper already enforces this,
|
||||
// but double-check in case of future code paths)
|
||||
if (dataUrlBytes(dataUrl) > MAX_BG_IMAGE_BYTES) {
|
||||
setMessage({ type: 'error', text: 'Image is too large. Please try a smaller photo.' })
|
||||
return
|
||||
}
|
||||
|
||||
// Prepend to history, deduplicate, cap at MAX_BG_HISTORY
|
||||
let newHistory = [dataUrl, ...bgImages.filter(i => i !== dataUrl)].slice(0, MAX_BG_HISTORY)
|
||||
|
||||
// Guard: total payload must stay within limit — drop oldest images until it fits
|
||||
while (newHistory.reduce((sum, img) => sum + dataUrlBytes(img), 0) > MAX_BG_PAYLOAD_BYTES) {
|
||||
newHistory = newHistory.slice(0, -1)
|
||||
}
|
||||
|
||||
await bgUpdate({ backgroundImage: dataUrl, backgroundImages: newHistory })
|
||||
}
|
||||
|
||||
const handleCropCancel = () => {
|
||||
if (cropperSrc) URL.revokeObjectURL(cropperSrc)
|
||||
setCropperSrc(null)
|
||||
}
|
||||
|
||||
// Apply theme to DOM
|
||||
const applyTheme = useCallback((t: 'light' | 'dark') => {
|
||||
const applyTheme = useCallback((t: 'light' | 'dark' | 'liquid-glass') => {
|
||||
document.documentElement.setAttribute('data-theme', t)
|
||||
localStorage.setItem('gj-theme', t)
|
||||
}, [])
|
||||
@@ -135,10 +239,11 @@ export default function SettingsPage() {
|
||||
applyTheme(theme)
|
||||
}, [theme, applyTheme])
|
||||
|
||||
const handleThemeChange = (newTheme: 'light' | 'dark') => {
|
||||
const handleThemeChange = (newTheme: 'light' | 'dark' | 'liquid-glass') => {
|
||||
setTheme(newTheme)
|
||||
applyTheme(newTheme)
|
||||
setMessage({ type: 'success', text: `Switched to ${newTheme === 'light' ? 'Light' : 'Dark'} theme` })
|
||||
const label = newTheme === 'light' ? 'Light' : newTheme === 'dark' ? 'Dark' : 'Liquid Glass'
|
||||
setMessage({ type: 'success', text: `Switched to ${label} theme` })
|
||||
setTimeout(() => setMessage(null), 2000)
|
||||
}
|
||||
|
||||
@@ -182,6 +287,30 @@ 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 handleSignOut = async () => {
|
||||
try {
|
||||
await signOut()
|
||||
@@ -318,6 +447,31 @@ export default function SettingsPage() {
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="settings-divider"></div>
|
||||
|
||||
{/* Daily Reminder */}
|
||||
<button
|
||||
type="button"
|
||||
className="settings-item settings-item-button"
|
||||
onClick={handleOpenReminderModal}
|
||||
>
|
||||
<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>
|
||||
<div className="settings-item-content">
|
||||
<h4 className="settings-item-title">Daily Reminder</h4>
|
||||
<p className="settings-item-subtitle">
|
||||
{reminderEnabled && reminderTime ? `Set for ${reminderTime}` : 'Set a daily reminder' }
|
||||
</p>
|
||||
</div>
|
||||
<svg className="settings-item-arrow" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -347,6 +501,27 @@ export default function SettingsPage() {
|
||||
<div className="settings-divider"></div>
|
||||
*/}
|
||||
|
||||
<button type="button" className="settings-item settings-item-button" onClick={() => setShowBgModal(true)}>
|
||||
<div className="settings-item-icon settings-item-icon-blue">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<polyline points="21 15 16 10 5 21" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="settings-item-content">
|
||||
<h4 className="settings-item-title">Background</h4>
|
||||
<p className="settings-item-subtitle">
|
||||
{activeImage ? 'Custom image active' : bgImages.length > 0 ? `${bgImages.length} saved` : 'Default color'}
|
||||
</p>
|
||||
</div>
|
||||
<svg className="settings-item-arrow" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="settings-divider"></div>
|
||||
|
||||
<div id="tour-theme-switcher" className="settings-item">
|
||||
<div className="settings-item-icon settings-item-icon-blue">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
@@ -359,7 +534,9 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
<div className="settings-item-content">
|
||||
<h4 className="settings-item-title">Theme</h4>
|
||||
<p className="settings-item-subtitle">Currently: {theme === 'light' ? 'Light' : 'Dark'}</p>
|
||||
<p className="settings-item-subtitle">
|
||||
Currently: {theme === 'light' ? 'Light' : theme === 'dark' ? 'Dark' : 'Liquid Glass'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="settings-theme-colors">
|
||||
<button
|
||||
@@ -374,6 +551,12 @@ export default function SettingsPage() {
|
||||
className={`settings-theme-dot settings-theme-dot-dark${theme === 'dark' ? ' settings-theme-dot-active' : ''}`}
|
||||
title="Dark theme"
|
||||
></button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleThemeChange('liquid-glass')}
|
||||
className={`settings-theme-dot settings-theme-dot-glass${theme === 'liquid-glass' ? ' settings-theme-dot-active' : ''}`}
|
||||
title="Liquid Glass theme"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -622,6 +805,206 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Background Image Gallery Modal */}
|
||||
{showBgModal && (
|
||||
<div className="confirm-modal-overlay" onClick={() => !bgApplying && setShowBgModal(false)}>
|
||||
<div className="bg-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="edit-modal-title" style={{ marginBottom: '0.25rem' }}>Background</h3>
|
||||
<p className="settings-item-subtitle" style={{ marginBottom: '1rem' }}>
|
||||
Add new images or select from previously used ones:
|
||||
</p>
|
||||
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
ref={bgFileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleBgFileSelect}
|
||||
/>
|
||||
|
||||
{/* Fixed 4-tile grid: [+] [slot1] [slot2] [slot3] */}
|
||||
<div className="bg-grid">
|
||||
{/* Add new — always first tile */}
|
||||
<button
|
||||
type="button"
|
||||
className="bg-grid-tile bg-grid-add"
|
||||
style={{ aspectRatio: screenAspect }}
|
||||
onClick={() => bgFileInputRef.current?.click()}
|
||||
disabled={bgApplying}
|
||||
title="Upload new image"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* 3 image slots — filled or empty placeholder */}
|
||||
{Array.from({ length: MAX_BG_HISTORY }).map((_, i) => {
|
||||
const img = bgImages[i]
|
||||
if (img) {
|
||||
return (
|
||||
<div key={i} className="bg-grid-wrapper">
|
||||
<button
|
||||
type="button"
|
||||
className={`bg-grid-tile bg-grid-thumb${img === activeImage ? ' bg-grid-tile--active' : ''}`}
|
||||
style={{ aspectRatio: screenAspect }}
|
||||
onClick={() => handleApplyFromGallery(img)}
|
||||
disabled={bgApplying}
|
||||
title={`Background ${i + 1}`}
|
||||
>
|
||||
<img src={img} alt="" className="bg-gallery-thumb-img" />
|
||||
{img === activeImage && (
|
||||
<div className="bg-gallery-badge">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="bg-tile-delete"
|
||||
onClick={(e) => handleDeleteBgImage(img, e)}
|
||||
disabled={bgApplying}
|
||||
title="Remove"
|
||||
>
|
||||
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3.5" strokeLinecap="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div key={i} className="bg-grid-tile bg-grid-empty" style={{ aspectRatio: screenAspect }} />
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Revert to default — only shown when a custom bg is active */}
|
||||
{activeImage && (
|
||||
<button
|
||||
type="button"
|
||||
className="bg-default-btn"
|
||||
onClick={handleApplyDefault}
|
||||
disabled={bgApplying}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
</svg>
|
||||
Revert to default color
|
||||
</button>
|
||||
)}
|
||||
|
||||
{bgApplying && (
|
||||
<p className="settings-item-subtitle" style={{ textAlign: 'center', marginTop: '0.5rem' }}>
|
||||
Saving…
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="bg-close-btn"
|
||||
onClick={() => setShowBgModal(false)}
|
||||
disabled={bgApplying}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fullscreen image cropper */}
|
||||
{cropperSrc && (
|
||||
<BgImageCropper
|
||||
imageSrc={cropperSrc}
|
||||
aspectRatio={window.innerWidth / window.innerHeight}
|
||||
onCrop={handleCropDone}
|
||||
onCancel={handleCropCancel}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Daily Reminder Modal */}
|
||||
{showReminderModal && (
|
||||
<div className="confirm-modal-overlay reminder-modal-overlay" onClick={() => !reminderSaving && setShowReminderModal(false)}>
|
||||
<div className="confirm-modal reminder-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div style={{ fontSize: '1.75rem', textAlign: 'center', marginBottom: '0.25rem' }}>🔔</div>
|
||||
<h3 className="edit-modal-title" style={{ marginBottom: '0.5rem' }}>
|
||||
{reminderTime ? 'Edit Reminder' : 'Set Daily Reminder'}
|
||||
</h3>
|
||||
<ClockTimePicker
|
||||
value={reminderPickedTime}
|
||||
onChange={setReminderPickedTime}
|
||||
disabled={reminderSaving}
|
||||
/>
|
||||
|
||||
{reminderError && (
|
||||
<p style={{
|
||||
color: 'var(--color-error, #ef4444)',
|
||||
fontSize: '0.8rem',
|
||||
marginTop: '0.5rem',
|
||||
textAlign: 'center',
|
||||
lineHeight: 1.4,
|
||||
}}>
|
||||
{reminderError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="confirm-modal-actions" style={{ marginTop: '0.75rem' }}>
|
||||
<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>
|
||||
{reminderEnabled && reminderTime && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (!user || !userId) return
|
||||
setReminderSaving(true)
|
||||
const authToken = await user.getIdToken()
|
||||
await disableReminder(userId, authToken)
|
||||
setReminderEnabled(false)
|
||||
setReminderSaving(false)
|
||||
setShowReminderModal(false)
|
||||
setMessage({ type: 'success', text: 'Reminder disabled' })
|
||||
setTimeout(() => setMessage(null), 2000)
|
||||
}}
|
||||
disabled={reminderSaving}
|
||||
style={{
|
||||
marginTop: '0.5rem',
|
||||
width: '100%',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'var(--color-error, #ef4444)',
|
||||
fontSize: '0.85rem',
|
||||
cursor: 'pointer',
|
||||
padding: '0.4rem',
|
||||
}}
|
||||
>
|
||||
Disable Reminder
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<BottomNav />
|
||||
</div>
|
||||
)
|
||||
|
||||
108
src/pages/TermsOfServicePage.tsx
Normal file
108
src/pages/TermsOfServicePage.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { usePageMeta } from '../hooks/usePageMeta'
|
||||
|
||||
export default function TermsOfServicePage() {
|
||||
usePageMeta({
|
||||
title: 'Terms of Service | Grateful Journal',
|
||||
description: 'Terms of Service for Grateful Journal — a free, private gratitude journal app. Read about the rules and guidelines for using the service.',
|
||||
canonical: 'https://gratefuljournal.online/termsofservice',
|
||||
ogTitle: 'Terms of Service | Grateful Journal',
|
||||
ogDescription: 'Terms of Service for Grateful Journal — a free, private gratitude journal app. Read about the rules and guidelines for using the service.',
|
||||
})
|
||||
return (
|
||||
<div className="static-page">
|
||||
<header className="static-page__header">
|
||||
<Link to="/" className="static-page__logo">🌱 Grateful Journal</Link>
|
||||
</header>
|
||||
|
||||
<main className="static-page__content">
|
||||
<h1>Terms of Service</h1>
|
||||
<p className="static-page__updated">Last updated: April 14, 2026</p>
|
||||
|
||||
<p>
|
||||
By using Grateful Journal, you agree to these Terms of Service. Please read them carefully.
|
||||
</p>
|
||||
|
||||
<h2>1. Use of the Service</h2>
|
||||
<p>
|
||||
Grateful Journal is a personal journaling app. You may use it for your own personal,
|
||||
non-commercial journaling purposes. You must be at least 13 years old to use the service.
|
||||
</p>
|
||||
|
||||
<h2>2. Your Account</h2>
|
||||
<p>
|
||||
You are responsible for maintaining the security of your account. We use Google Sign-In for
|
||||
authentication. You agree to provide accurate information and to keep your account credentials
|
||||
confidential. Notify us immediately if you suspect unauthorized access to your account.
|
||||
</p>
|
||||
|
||||
<h2>3. Your Content</h2>
|
||||
<p>
|
||||
You own all journal entries and content you create. We do not claim any ownership over your
|
||||
content. Your journal entries are end-to-end encrypted and inaccessible to us. App preferences
|
||||
such as your display name, profile photo, and background images are stored as plain account
|
||||
settings and are accessible to us at the database level. You are solely responsible for the
|
||||
content you store in the app, including any images you upload as backgrounds.
|
||||
</p>
|
||||
|
||||
<h2>4. Prohibited Conduct</h2>
|
||||
<p>You agree not to:</p>
|
||||
<ul>
|
||||
<li>Use the service for any unlawful purpose or in violation of any applicable laws.</li>
|
||||
<li>Attempt to gain unauthorized access to any part of the service or its infrastructure.</li>
|
||||
<li>Reverse-engineer, decompile, or otherwise attempt to extract the source code of the app.</li>
|
||||
<li>Use the service to distribute malware or harmful code.</li>
|
||||
<li>Abuse or overload the service in a way that impairs its operation for other users.</li>
|
||||
</ul>
|
||||
|
||||
<h2>5. Service Availability</h2>
|
||||
<p>
|
||||
We strive to keep Grateful Journal available at all times, but we do not guarantee
|
||||
uninterrupted access. We may perform maintenance, updates, or changes that temporarily
|
||||
affect availability. We are not liable for any downtime or data loss.
|
||||
</p>
|
||||
|
||||
<h2>6. Account Termination</h2>
|
||||
<p>
|
||||
You may delete your account at any time from the Settings page. Deletion permanently removes
|
||||
your account and all associated data. We reserve the right to suspend or terminate accounts
|
||||
that violate these terms.
|
||||
</p>
|
||||
|
||||
<h2>7. Disclaimer of Warranties</h2>
|
||||
<p>
|
||||
Grateful Journal is provided "as is" without warranties of any kind, express or implied.
|
||||
We do not warrant that the service will be error-free, secure, or continuously available.
|
||||
Use of the service is at your own risk.
|
||||
</p>
|
||||
|
||||
<h2>8. Limitation of Liability</h2>
|
||||
<p>
|
||||
To the maximum extent permitted by law, Grateful Journal and its creators shall not be
|
||||
liable for any indirect, incidental, special, or consequential damages arising from your
|
||||
use of the service, including loss of data.
|
||||
</p>
|
||||
|
||||
<h2>9. Changes to These Terms</h2>
|
||||
<p>
|
||||
We may update these Terms of Service from time to time. We will indicate the date of the
|
||||
last update at the top of this page. Continued use of the service after changes constitutes
|
||||
acceptance of the updated terms.
|
||||
</p>
|
||||
|
||||
<h2>10. Contact</h2>
|
||||
<p>
|
||||
Questions about these terms? Reach us at the contact details on our <Link to="/about">About page</Link>.
|
||||
</p>
|
||||
</main>
|
||||
|
||||
<footer className="static-page__footer">
|
||||
<Link to="/">← Back to Grateful Journal</Link>
|
||||
<span>·</span>
|
||||
<Link to="/privacy">Privacy Policy</Link>
|
||||
<span>·</span>
|
||||
<Link to="/about">About</Link>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
108
termsofservice.html
Normal file
108
termsofservice.html
Normal file
@@ -0,0 +1,108 @@
|
||||
<!doctype html>
|
||||
<html lang="en" style="background-color:#eef6ee">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="Grateful Journal" />
|
||||
<meta name="theme-color" content="#16a34a" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
|
||||
/>
|
||||
|
||||
<!-- SEO -->
|
||||
<title>Terms of Service | Grateful Journal</title>
|
||||
<meta name="description" content="Terms of Service for Grateful Journal — a free, private gratitude journal app. Read about the rules and guidelines for using the service." />
|
||||
<meta name="keywords" content="grateful journal terms of service, gratitude journal app terms, journal app conditions" />
|
||||
<meta name="robots" content="index, follow, max-snippet:160, max-image-preview:large" />
|
||||
<link rel="canonical" href="https://gratefuljournal.online/termsofservice" />
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<meta property="og:url" content="https://gratefuljournal.online/termsofservice" />
|
||||
<meta property="og:title" content="Terms of Service | Grateful Journal" />
|
||||
<meta property="og:description" content="Terms of Service for Grateful Journal — a free, private gratitude journal app. Read about the rules and guidelines for using the service." />
|
||||
<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="Terms of Service | Grateful Journal" />
|
||||
<meta name="twitter:description" content="Terms of Service for Grateful Journal — a free, private gratitude journal app." />
|
||||
<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: WebPage -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
"name": "Terms of Service",
|
||||
"url": "https://gratefuljournal.online/termsofservice",
|
||||
"description": "Terms of Service for Grateful Journal — a free, private gratitude journal app.",
|
||||
"isPartOf": {
|
||||
"@type": "WebSite",
|
||||
"name": "Grateful Journal",
|
||||
"url": "https://gratefuljournal.online/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<noscript>
|
||||
<main style="font-family:sans-serif;max-width:680px;margin:4rem auto;padding:1rem 1.5rem;color:#1a1a1a;line-height:1.7">
|
||||
<nav style="margin-bottom:2rem"><a href="/" style="color:#15803d">← Grateful Journal</a></nav>
|
||||
|
||||
<h1 style="color:#15803d">Terms of Service</h1>
|
||||
<p><em>Last updated: April 14, 2026</em></p>
|
||||
|
||||
<p>By using Grateful Journal, you agree to these Terms of Service. Please read them carefully.</p>
|
||||
|
||||
<h2>1. Use of the Service</h2>
|
||||
<p>Grateful Journal is a personal journaling app. You may use it for your own personal, non-commercial journaling purposes. You must be at least 13 years old to use the service.</p>
|
||||
|
||||
<h2>2. Your Account</h2>
|
||||
<p>You are responsible for maintaining the security of your account. We use Google Sign-In for authentication. Notify us immediately if you suspect unauthorized access to your account.</p>
|
||||
|
||||
<h2>3. Your Content</h2>
|
||||
<p>You own all journal entries and content you create. Your journal entries are end-to-end encrypted and inaccessible to us. You are solely responsible for the content you store in the app.</p>
|
||||
|
||||
<h2>4. Prohibited Conduct</h2>
|
||||
<p>You agree not to use the service for any unlawful purpose, attempt to gain unauthorized access to the service, or abuse the service in a way that impairs its operation for other users.</p>
|
||||
|
||||
<h2>5. Service Availability</h2>
|
||||
<p>We strive to keep Grateful Journal available at all times but do not guarantee uninterrupted access. We are not liable for any downtime or data loss.</p>
|
||||
|
||||
<h2>6. Account Termination</h2>
|
||||
<p>You may delete your account at any time from the Settings page. Deletion permanently removes your account and all associated data.</p>
|
||||
|
||||
<h2>7. Disclaimer of Warranties</h2>
|
||||
<p>Grateful Journal is provided "as is" without warranties of any kind. Use of the service is at your own risk.</p>
|
||||
|
||||
<h2>8. Limitation of Liability</h2>
|
||||
<p>To the maximum extent permitted by law, Grateful Journal and its creators shall not be liable for any indirect, incidental, or consequential damages arising from your use of the service.</p>
|
||||
|
||||
<h2>9. Changes to These Terms</h2>
|
||||
<p>We may update these Terms of Service from time to time. Continued use of the service after changes constitutes acceptance of the updated terms.</p>
|
||||
|
||||
<nav style="margin-top:2rem">
|
||||
<a href="/">← Back to Grateful Journal</a> ·
|
||||
<a href="/privacy">Privacy Policy</a> ·
|
||||
<a href="/about">About</a>
|
||||
</nav>
|
||||
</main>
|
||||
</noscript>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,17 +1,47 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import path, { resolve } from 'path'
|
||||
|
||||
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> = {}
|
||||
|
||||
function swBuildTimePlugin() {
|
||||
return {
|
||||
name: 'sw-build-time',
|
||||
name: 'sw-plugin',
|
||||
config(_: unknown, { mode }: { mode: string }) {
|
||||
env = loadEnv(mode, process.cwd(), '')
|
||||
},
|
||||
// Dev server: serve sw.js with injected Firebase 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('/sw.js', (_req, res) => {
|
||||
const swPath = path.resolve(__dirname, 'public/sw.js')
|
||||
if (fs.existsSync(swPath)) {
|
||||
const content = injectFirebaseConfig(
|
||||
fs.readFileSync(swPath, 'utf-8').replace('__BUILD_TIME__', 'dev'),
|
||||
env
|
||||
)
|
||||
res.setHeader('Content-Type', 'application/javascript')
|
||||
res.end(content)
|
||||
}
|
||||
})
|
||||
},
|
||||
closeBundle() {
|
||||
// Cache-bust sw.js and inject Firebase config
|
||||
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))
|
||||
let content = fs.readFileSync(swPath, 'utf-8')
|
||||
content = content.replace('__BUILD_TIME__', Date.now().toString())
|
||||
content = injectFirebaseConfig(content, env)
|
||||
fs.writeFileSync(swPath, content)
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -19,7 +49,7 @@ function swBuildTimePlugin() {
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), swBuildTimePlugin()],
|
||||
plugins: [react(), swPlugin()],
|
||||
server: {
|
||||
port: 8000,
|
||||
strictPort: false,
|
||||
@@ -27,5 +57,36 @@ export default defineConfig({
|
||||
optimizeDeps: {
|
||||
include: ['libsodium-wrappers'],
|
||||
},
|
||||
build: {
|
||||
chunkSizeWarningLimit: 1000,
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: resolve(__dirname, 'index.html'),
|
||||
about: resolve(__dirname, 'about.html'),
|
||||
privacy: resolve(__dirname, 'privacy.html'),
|
||||
termsofservice: resolve(__dirname, 'termsofservice.html'),
|
||||
},
|
||||
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