Compare commits

...

12 Commits

Author SHA1 Message Date
55e5e06da8 tour fix 2026-04-21 12:01:53 +05:30
6ea06d2d3b safari fix 2026-04-21 11:49:48 +05:30
cfd24de69d Revert "writing area dynamic for phones"
This reverts commit 7ddb463b9f.
2026-04-21 11:36:47 +05:30
7ddb463b9f writing area dynamic for phones 2026-04-21 11:33:01 +05:30
4500fde334 photo ui update 2026-04-21 11:22:14 +05:30
8f6c705677 updated colors 2026-04-21 11:09:03 +05:30
9f944e531a clock ui update 2026-04-21 10:49:13 +05:30
11d8e5daa6 home page redirect loading fix 2026-04-21 10:39:13 +05:30
05fcb0a0d5 build fix 2026-04-20 15:33:29 +05:30
d6da8177c1 docker update 2026-04-20 15:31:02 +05:30
237ba6b3c1 notification working 2026-04-20 15:23:28 +05:30
93dbf2023c more fix 2026-04-16 15:28:12 +05:30
19 changed files with 747 additions and 247 deletions

View File

@@ -8,6 +8,7 @@ ARG VITE_FIREBASE_PROJECT_ID
ARG VITE_FIREBASE_STORAGE_BUCKET ARG VITE_FIREBASE_STORAGE_BUCKET
ARG VITE_FIREBASE_MESSAGING_SENDER_ID ARG VITE_FIREBASE_MESSAGING_SENDER_ID
ARG VITE_FIREBASE_APP_ID ARG VITE_FIREBASE_APP_ID
ARG VITE_FIREBASE_VAPID_KEY
ARG VITE_API_URL=/api ARG VITE_API_URL=/api
ENV VITE_FIREBASE_API_KEY=${VITE_FIREBASE_API_KEY} 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_STORAGE_BUCKET=${VITE_FIREBASE_STORAGE_BUCKET}
ENV VITE_FIREBASE_MESSAGING_SENDER_ID=${VITE_FIREBASE_MESSAGING_SENDER_ID} ENV VITE_FIREBASE_MESSAGING_SENDER_ID=${VITE_FIREBASE_MESSAGING_SENDER_ID}
ENV VITE_FIREBASE_APP_ID=${VITE_FIREBASE_APP_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} ENV VITE_API_URL=${VITE_API_URL}
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./

328
REMINDER_FEATURE_SETUP.md Normal file
View 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

View File

@@ -1,5 +1,8 @@
from pydantic_settings import BaseSettings, SettingsConfigDict # type: ignore from pydantic_settings import BaseSettings, SettingsConfigDict # type: ignore
from functools import lru_cache from functools import lru_cache
from pathlib import Path
_ENV_FILE = str(Path(__file__).parent / ".env")
class Settings(BaseSettings): class Settings(BaseSettings):
@@ -12,7 +15,7 @@ class Settings(BaseSettings):
firebase_service_account_json: str = "" firebase_service_account_json: str = ""
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file=".env", env_file=_ENV_FILE,
case_sensitive=False, case_sensitive=False,
extra="ignore", # ignore unknown env vars (e.g. VITE_* from root .env) extra="ignore", # ignore unknown env vars (e.g. VITE_* from root .env)
) )

View File

@@ -1,3 +1,4 @@
import logging
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from db import MongoDB from db import MongoDB
@@ -7,6 +8,13 @@ from routers import notifications
from scheduler import start_scheduler from scheduler import start_scheduler
from contextlib import asynccontextmanager 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() settings = get_settings()
_scheduler = None _scheduler = None

View File

@@ -54,6 +54,7 @@ async def register_user(user_data: UserCreate):
"theme": user.get("theme", "light"), "theme": user.get("theme", "light"),
"backgroundImage": user.get("backgroundImage"), "backgroundImage": user.get("backgroundImage"),
"backgroundImages": user.get("backgroundImages", []), "backgroundImages": user.get("backgroundImages", []),
"reminder": user.get("reminder"),
"createdAt": user["createdAt"].isoformat(), "createdAt": user["createdAt"].isoformat(),
"updatedAt": user["updatedAt"].isoformat(), "updatedAt": user["updatedAt"].isoformat(),
"message": "User registered successfully" if result.upserted_id else "User already exists" "message": "User registered successfully" if result.upserted_id else "User already exists"
@@ -83,6 +84,7 @@ async def get_user_by_email(email: str):
"theme": user.get("theme", "light"), "theme": user.get("theme", "light"),
"backgroundImage": user.get("backgroundImage"), "backgroundImage": user.get("backgroundImage"),
"backgroundImages": user.get("backgroundImages", []), "backgroundImages": user.get("backgroundImages", []),
"reminder": user.get("reminder"),
"tutorial": user.get("tutorial"), "tutorial": user.get("tutorial"),
"createdAt": user["createdAt"].isoformat(), "createdAt": user["createdAt"].isoformat(),
"updatedAt": user["updatedAt"].isoformat() "updatedAt": user["updatedAt"].isoformat()

View File

@@ -49,79 +49,109 @@ def init_firebase():
def send_reminder_notifications(): def send_reminder_notifications():
"""Check all users and send reminders where due.""" """Check all users and send reminders where due."""
if not _firebase_initialized: if not _firebase_initialized:
log.warning("Reminder check skipped — Firebase not initialized")
return return
db = get_database() db = get_database()
now_utc = datetime.utcnow().replace(second=0, microsecond=0) now_utc = datetime.utcnow().replace(second=0, microsecond=0)
# Find all users with reminder enabled and at least one FCM token candidates = list(db.users.find({
users = db.users.find({
"reminder.enabled": True, "reminder.enabled": True,
"fcmTokens": {"$exists": True, "$not": {"$size": 0}}, "fcmTokens": {"$exists": True, "$not": {"$size": 0}},
"reminder.time": {"$exists": True}, }))
})
for user in users: log.debug(f"Reminder check at {now_utc.strftime('%H:%M')} UTC — {len(candidates)} candidate(s)")
for user in candidates:
try: try:
if user.get("reminder", {}).get("time"):
_process_user(db, user, now_utc) _process_user(db, user, now_utc)
_process_universal(db, user, now_utc)
except Exception as e: except Exception as e:
log.error(f"Error processing reminder for user {user.get('_id')}: {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): def _process_user(db, user: dict, now_utc: datetime):
uid = user.get("_id")
reminder = user.get("reminder", {}) reminder = user.get("reminder", {})
reminder_time_str = reminder.get("time") # "HH:MM" reminder_time_str = reminder.get("time")
timezone_str = reminder.get("timezone", "UTC") timezone_str = reminder.get("timezone", "UTC")
fcm_tokens: list = user.get("fcmTokens", []) fcm_tokens: list = user.get("fcmTokens", [])
if not reminder_time_str or not fcm_tokens: if not reminder_time_str or not fcm_tokens:
return return
try: now_local, today_str, user_tz = _get_user_local_time(now_utc, timezone_str)
user_tz = pytz.timezone(timezone_str)
except pytz.UnknownTimeZoneError:
user_tz = pytz.utc
# Current time in user's timezone
now_local = now_utc.replace(tzinfo=pytz.utc).astimezone(user_tz)
current_hm = now_local.strftime("%H:%M") current_hm = now_local.strftime("%H:%M")
if current_hm != reminder_time_str: if current_hm != reminder_time_str:
return # Not the right minute log.debug(f"User {uid}: skipped — current time {current_hm} != reminder time {reminder_time_str} ({timezone_str})")
# Check if already notified today (in user's local date)
today_local_str = now_local.strftime("%Y-%m-%d")
last_notified = reminder.get("lastNotifiedDate", "")
if last_notified == today_local_str:
return # Already sent today
# Check if user has already written today (using createdAt in their timezone)
today_start_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0)
today_start_utc = today_start_local.astimezone(pytz.utc).replace(tzinfo=None)
today_end_utc = today_start_utc + timedelta(days=1)
entry_count = db.entries.count_documents({
"userId": user["_id"],
"createdAt": {"$gte": today_start_utc, "$lt": today_end_utc},
})
if entry_count > 0:
# Already wrote today — mark notified to avoid repeated checks
db.users.update_one(
{"_id": user["_id"]},
{"$set": {"reminder.lastNotifiedDate": today_local_str}}
)
return return
# Send FCM notification if _wrote_today(db, uid, now_local, user_tz):
_send_push(user["_id"], fcm_tokens, db, today_local_str) 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 _send_push(user_id, tokens: list, db, today_local_str: str): 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.""" """Send FCM multicast and prune stale tokens."""
title = "Last chance to journal today 🌙" if universal else "Time to journal 🌱"
message = messaging.MulticastMessage( message = messaging.MulticastMessage(
notification=messaging.Notification( notification=messaging.Notification(
title="Time to journal 🌱", title=title,
body="You haven't written today yet. Take a moment to reflect.", body="You haven't written today yet. Take a moment to reflect.",
), ),
tokens=tokens, tokens=tokens,
@@ -143,7 +173,6 @@ def _send_push(user_id, tokens: list, db, today_local_str: str):
response = messaging.send_each_for_multicast(message) response = messaging.send_each_for_multicast(message)
log.info(f"Reminder sent to user {user_id}: {response.success_count} ok, {response.failure_count} failed") log.info(f"Reminder sent to user {user_id}: {response.success_count} ok, {response.failure_count} failed")
# Remove tokens that are no longer valid
stale_tokens = [ stale_tokens = [
tokens[i] for i, r in enumerate(response.responses) 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 not r.success and r.exception and "not-registered" in str(r.exception).lower()
@@ -155,12 +184,6 @@ def _send_push(user_id, tokens: list, db, today_local_str: str):
) )
log.info(f"Removed {len(stale_tokens)} stale FCM tokens for user {user_id}") log.info(f"Removed {len(stale_tokens)} stale FCM tokens for user {user_id}")
# Mark today as notified
db.users.update_one(
{"_id": user_id},
{"$set": {"reminder.lastNotifiedDate": today_local_str}}
)
def start_scheduler() -> BackgroundScheduler: def start_scheduler() -> BackgroundScheduler:
"""Initialize Firebase and start the minute-by-minute scheduler.""" """Initialize Firebase and start the minute-by-minute scheduler."""

View File

@@ -10,6 +10,7 @@ services:
VITE_FIREBASE_STORAGE_BUCKET: ${VITE_FIREBASE_STORAGE_BUCKET} VITE_FIREBASE_STORAGE_BUCKET: ${VITE_FIREBASE_STORAGE_BUCKET}
VITE_FIREBASE_MESSAGING_SENDER_ID: ${VITE_FIREBASE_MESSAGING_SENDER_ID} VITE_FIREBASE_MESSAGING_SENDER_ID: ${VITE_FIREBASE_MESSAGING_SENDER_ID}
VITE_FIREBASE_APP_ID: ${VITE_FIREBASE_APP_ID} VITE_FIREBASE_APP_ID: ${VITE_FIREBASE_APP_ID}
VITE_FIREBASE_VAPID_KEY: ${VITE_FIREBASE_VAPID_KEY}
VITE_API_URL: ${VITE_API_URL:-/api} VITE_API_URL: ${VITE_API_URL:-/api}
depends_on: depends_on:
backend: backend:

View File

@@ -26,7 +26,9 @@
background: #0a0a0a; background: #0a0a0a;
} }
.page-loader--transparent { .page-loader--transparent,
[data-theme="dark"] .page-loader--transparent,
[data-theme="liquid-glass"] .page-loader--transparent {
background: transparent; background: transparent;
} }
@@ -102,7 +104,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.75rem; gap: 1.75rem;
background: var(--color-surface); background: #fff;
border-radius: 24px; border-radius: 24px;
border-top: 4px solid #22c55e; border-top: 4px solid #22c55e;
padding: 2rem 1.75rem; padding: 2rem 1.75rem;
@@ -135,7 +137,7 @@
.lp__tagline { .lp__tagline {
margin: 0; margin: 0;
color: #6b7280; color: #4b505a;
font-size: 0.9375rem; font-size: 0.9375rem;
text-align: center; text-align: center;
line-height: 1.65; line-height: 1.65;
@@ -152,7 +154,7 @@
.lp__privacy { .lp__privacy {
margin: 0; margin: 0;
font-size: 0.8rem; font-size: 0.8rem;
color: #9ca3af; color: #6d727a;
text-align: center; text-align: center;
} }
@@ -166,6 +168,29 @@
text-align: left; text-align: left;
} }
/* Login page is always light — ignore dark / liquid-glass themes */
[data-theme="dark"] .lp,
[data-theme="liquid-glass"] .lp {
background: linear-gradient(160deg, #eef6ee 0%, #dcfce7 100%);
}
[data-theme="dark"] .lp__form,
[data-theme="liquid-glass"] .lp__form {
background: #fff;
backdrop-filter: none;
-webkit-backdrop-filter: none;
border: none;
border-top: 4px solid #22c55e;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08);
}
[data-theme="dark"] .lp__tagline,
[data-theme="liquid-glass"] .lp__tagline {
color: #4b505a;
}
[data-theme="dark"] .lp__privacy,
[data-theme="liquid-glass"] .lp__privacy {
color: #6d727a;
}
/* kept for other callers */ /* kept for other callers */
.login-card__error { .login-card__error {
margin: 0; margin: 0;
@@ -494,7 +519,7 @@
} }
.journal-title-input::placeholder { .journal-title-input::placeholder {
color: #c4bfb5; color: #8a8480;
} }
.journal-title-input:focus { .journal-title-input:focus {
border-bottom-color: #22c55e; border-bottom-color: #22c55e;
@@ -517,7 +542,7 @@
} }
.journal-entry-textarea::placeholder { .journal-entry-textarea::placeholder {
color: #c4bfb5; color: #8a8480;
font-style: italic; font-style: italic;
} }
@@ -670,12 +695,12 @@
border: none; border: none;
border-radius: 12px; border-radius: 12px;
cursor: pointer; cursor: pointer;
color: #9ca3af; color: #6d727a;
transition: all 0.2s ease; transition: all 0.2s ease;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
} }
.journal-icon-btn:hover { .journal-icon-btn:hover {
color: #6b7280; color: #4b505a;
background: rgba(0, 0, 0, 0.05); background: rgba(0, 0, 0, 0.05);
} }
@@ -858,7 +883,7 @@
border: none; border: none;
border-radius: 100px; border-radius: 100px;
cursor: pointer; cursor: pointer;
color: #9ca3af; color: #6d727a;
transition: all 0.18s ease; transition: all 0.18s ease;
min-height: 44px; min-height: 44px;
font-family: "Sniglet", system-ui; font-family: "Sniglet", system-ui;
@@ -903,7 +928,7 @@
} }
.bottom-nav-btn:hover { .bottom-nav-btn:hover {
color: #6b7280; color: #4b505a;
} }
.bottom-nav-btn-active { .bottom-nav-btn-active {
@@ -950,7 +975,7 @@
.history-subtitle { .history-subtitle {
margin: 0; margin: 0;
font-size: 0.875rem; font-size: 0.875rem;
color: #6b7280; color: #4b505a;
font-family: "Sniglet", system-ui; font-family: "Sniglet", system-ui;
} }
@@ -965,7 +990,7 @@
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
border-radius: 50%; border-radius: 50%;
cursor: pointer; cursor: pointer;
color: #6b7280; color: #4b505a;
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.history-search-btn:hover { .history-search-btn:hover {
@@ -1025,7 +1050,7 @@
border: none; border: none;
border-radius: 8px; border-radius: 8px;
cursor: pointer; cursor: pointer;
color: #9ca3af; color: #6d727a;
transition: all 0.15s ease; transition: all 0.15s ease;
} }
.calendar-nav-btn:hover { .calendar-nav-btn:hover {
@@ -1042,7 +1067,7 @@
.calendar-weekday { .calendar-weekday {
font-size: 0.6875rem; font-size: 0.6875rem;
font-weight: 500; font-weight: 500;
color: #9ca3af; color: #6d727a;
text-align: center; text-align: center;
padding: 4px 0; padding: 4px 0;
font-family: "Sniglet", system-ui; font-family: "Sniglet", system-ui;
@@ -1108,7 +1133,7 @@
font-size: 0.625rem; font-size: 0.625rem;
font-weight: 700; font-weight: 700;
letter-spacing: 0.12em; letter-spacing: 0.12em;
color: #9ca3af; color: #6d727a;
text-transform: uppercase; text-transform: uppercase;
font-family: "Sniglet", system-ui; font-family: "Sniglet", system-ui;
} }
@@ -1193,14 +1218,14 @@
font-size: 0.625rem; font-size: 0.625rem;
font-weight: 600; font-weight: 600;
letter-spacing: 0.08em; letter-spacing: 0.08em;
color: #6b7280; color: #4b505a;
text-transform: uppercase; text-transform: uppercase;
font-family: "Sniglet", system-ui; font-family: "Sniglet", system-ui;
} }
.entry-time { .entry-time {
font-size: 0.6875rem; font-size: 0.6875rem;
color: #9ca3af; color: #6d727a;
font-family: "Sniglet", system-ui; font-family: "Sniglet", system-ui;
} }
@@ -1217,7 +1242,7 @@
margin: 0; margin: 0;
font-size: 0.8125rem; font-size: 0.8125rem;
line-height: 1.5; line-height: 1.5;
color: #6b7280; color: #4b505a;
font-family: "Sniglet", system-ui; font-family: "Sniglet", system-ui;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
@@ -1245,7 +1270,7 @@
.settings-subtitle { .settings-subtitle {
margin: 0; margin: 0;
font-size: 0.875rem; font-size: 0.875rem;
color: #6b7280; color: #4b505a;
font-family: "Sniglet", system-ui; font-family: "Sniglet", system-ui;
} }
@@ -1306,13 +1331,20 @@
} }
.settings-profile-name { .settings-profile-name {
margin: 0 0 0.3rem; margin: 0 0 0.15rem;
font-size: 1.0625rem; font-size: 1.0625rem;
font-weight: 700; font-weight: 700;
color: #1a1a1a; color: #1a1a1a;
font-family: "Sniglet", system-ui; font-family: "Sniglet", system-ui;
} }
.settings-profile-email {
margin: 0;
font-size: 0.75rem;
color: var(--color-text-muted);
font-family: "Sniglet", system-ui;
}
.settings-profile-badge { .settings-profile-badge {
display: inline-block; display: inline-block;
padding: 0.15rem 0.5rem; padding: 0.15rem 0.5rem;
@@ -1358,8 +1390,7 @@
position: relative; position: relative;
width: 80px; width: 80px;
height: 80px; height: 80px;
margin: 0 auto 0.5rem; margin: 0 auto 0.75rem;
cursor: pointer;
border-radius: 50%; border-radius: 50%;
overflow: hidden; overflow: hidden;
} }
@@ -1385,30 +1416,44 @@
font-family: "Sniglet", system-ui; font-family: "Sniglet", system-ui;
} }
.edit-modal-avatar-overlay { .edit-modal-photo-actions {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.35);
display: flex; display: flex;
align-items: center;
justify-content: center; justify-content: center;
color: #fff; gap: 0.5rem;
border-radius: 50%; margin-bottom: 0.75rem;
}
.edit-modal-change-photo,
.edit-modal-remove-photo {
flex: 1;
max-width: 130px;
padding: 0.45rem 0.75rem;
border-radius: 8px;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
font-family: "Sniglet", system-ui;
transition: background 0.15s;
}
.edit-modal-change-photo {
background: var(--color-accent-light, #dcfce7);
border: 1.5px solid var(--color-primary, #22c55e);
color: var(--color-primary, #22c55e);
}
.edit-modal-change-photo:hover {
background: #bbf7d0;
} }
.edit-modal-remove-photo { .edit-modal-remove-photo {
display: block; background: #fef2f2;
margin: 0 auto 0.5rem; border: 1.5px solid #fca5a5;
background: none;
border: none;
color: #ef4444; color: #ef4444;
font-size: 0.75rem;
cursor: pointer;
padding: 0.25rem 0.5rem;
} }
.edit-modal-remove-photo:hover { .edit-modal-remove-photo:hover {
text-decoration: underline; background: #fee2e2;
} }
.edit-modal-save { .edit-modal-save {
@@ -1443,7 +1488,7 @@
font-size: 0.625rem; font-size: 0.625rem;
font-weight: 700; font-weight: 700;
letter-spacing: 0.12em; letter-spacing: 0.12em;
color: #9ca3af; color: #6d727a;
text-transform: uppercase; text-transform: uppercase;
font-family: "Sniglet", system-ui; font-family: "Sniglet", system-ui;
} }
@@ -1490,7 +1535,7 @@
} }
.settings-item-icon-gray { .settings-item-icon-gray {
background: rgba(107, 114, 128, 0.1); background: rgba(107, 114, 128, 0.1);
color: #6b7280; color: #4b505a;
} }
.settings-item-icon-orange { .settings-item-icon-orange {
background: rgba(251, 146, 60, 0.12); background: rgba(251, 146, 60, 0.12);
@@ -1521,7 +1566,7 @@
.settings-item-subtitle { .settings-item-subtitle {
margin: 0; margin: 0;
font-size: 0.75rem; font-size: 0.75rem;
color: #9ca3af; color: #6d727a;
font-family: "Sniglet", system-ui; font-family: "Sniglet", system-ui;
} }
@@ -1651,7 +1696,8 @@
width: 100%; width: 100%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: center;
gap: 0.5rem;
padding: 0.875rem 1.125rem; padding: 0.875rem 1.125rem;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
font-size: 0.9375rem; font-size: 0.9375rem;
@@ -1676,7 +1722,7 @@
margin-bottom: 1rem; margin-bottom: 1rem;
font-size: 0.9375rem; font-size: 0.9375rem;
font-weight: 600; font-weight: 600;
color: #6b7280; color: #4b505a;
background: var(--color-surface); background: var(--color-surface);
border: none; border: none;
border-radius: 14px; border-radius: 14px;
@@ -1775,14 +1821,14 @@
font-size: 0.625rem; font-size: 0.625rem;
font-weight: 700; font-weight: 700;
letter-spacing: 0.1em; letter-spacing: 0.1em;
color: #6b7280; color: #4b505a;
text-transform: uppercase; text-transform: uppercase;
font-family: "Sniglet", system-ui; font-family: "Sniglet", system-ui;
} }
.entry-modal-time { .entry-modal-time {
font-size: 0.6875rem; font-size: 0.6875rem;
color: #9ca3af; color: #6d727a;
font-family: "Sniglet", system-ui; font-family: "Sniglet", system-ui;
} }
@@ -1796,7 +1842,7 @@
border: none; border: none;
border-radius: 50%; border-radius: 50%;
background: #f3f4f6; background: #f3f4f6;
color: #6b7280; color: #4b505a;
cursor: pointer; cursor: pointer;
transition: transition:
background 0.2s, background 0.2s,
@@ -1835,7 +1881,7 @@
} }
.entry-modal-empty { .entry-modal-empty {
color: #9ca3af; color: #6d727a;
font-style: italic; font-style: italic;
} }
@@ -1894,7 +1940,7 @@
} }
.delete-confirm-body { .delete-confirm-body {
color: #6b7280; color: #4b505a;
font-size: 0.9rem; font-size: 0.9rem;
font-family: "Sniglet", system-ui; font-family: "Sniglet", system-ui;
margin: 0 0 1.5rem; margin: 0 0 1.5rem;
@@ -2110,7 +2156,7 @@
} }
.confirm-modal { .confirm-modal {
background: var(--color-surface); background: #ffffff;
border-radius: 20px; border-radius: 20px;
padding: 1.75rem; padding: 1.75rem;
max-width: 380px; max-width: 380px;
@@ -2134,7 +2180,7 @@
.confirm-modal-desc { .confirm-modal-desc {
font-size: 0.85rem; font-size: 0.85rem;
color: #6b7280; color: #4b505a;
margin: 0 0 1rem; margin: 0 0 1rem;
line-height: 1.5; line-height: 1.5;
} }
@@ -2229,7 +2275,7 @@
.ios-install-subtitle { .ios-install-subtitle {
font-size: 0.85rem; font-size: 0.85rem;
color: #6b7280; color: #4b505a;
margin: 0 0 1rem; margin: 0 0 1rem;
text-align: center; text-align: center;
} }
@@ -2490,7 +2536,7 @@
/* -- Placeholder text -- */ /* -- Placeholder text -- */
[data-theme="dark"] .journal-title-input::placeholder, [data-theme="dark"] .journal-title-input::placeholder,
[data-theme="dark"] .journal-entry-textarea::placeholder { [data-theme="dark"] .journal-entry-textarea::placeholder {
color: #4a5a4a; color: #8a9e8a;
} }
/* -- Muted text -- */ /* -- Muted text -- */
@@ -2504,7 +2550,7 @@
[data-theme="dark"] .settings-section-title, [data-theme="dark"] .settings-section-title,
[data-theme="dark"] .recent-entries-title, [data-theme="dark"] .recent-entries-title,
[data-theme="dark"] .calendar-weekday { [data-theme="dark"] .calendar-weekday {
color: #7a8a7a; color: #a2ada2;
} }
/* -- Green accent text brighter in dark -- */ /* -- Green accent text brighter in dark -- */
@@ -2516,7 +2562,7 @@
/* -- Calendar -- */ /* -- Calendar -- */
[data-theme="dark"] .calendar-day { [data-theme="dark"] .calendar-day {
color: #b0b8b0; color: #c8cdc8;
} }
[data-theme="dark"] .calendar-day:not(.calendar-day-empty):hover { [data-theme="dark"] .calendar-day:not(.calendar-day-empty):hover {
@@ -2636,7 +2682,7 @@
[data-theme="dark"] .settings-item-icon-gray { [data-theme="dark"] .settings-item-icon-gray {
background: rgba(156, 163, 175, 0.1); background: rgba(156, 163, 175, 0.1);
color: #9ca3af; color: #babfc7;
} }
[data-theme="dark"] .settings-item-icon-orange { [data-theme="dark"] .settings-item-icon-orange {
@@ -2689,7 +2735,7 @@
[data-theme="dark"] .settings-signout-btn { [data-theme="dark"] .settings-signout-btn {
background: var(--color-surface); background: var(--color-surface);
color: #9ca3af; color: #babfc7;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
} }
@@ -2755,7 +2801,7 @@
[data-theme="dark"] .history-search-btn { [data-theme="dark"] .history-search-btn {
background: var(--color-surface); background: var(--color-surface);
border-color: #2a2a2a; border-color: #2a2a2a;
color: #7a8a7a; color: #a2ada2;
} }
[data-theme="dark"] .history-search-btn:hover { [data-theme="dark"] .history-search-btn:hover {
@@ -2796,11 +2842,11 @@
} }
[data-theme="dark"] .entry-modal-content { [data-theme="dark"] .entry-modal-content {
color: #b0b8b0; color: #c8cdc8;
} }
[data-theme="dark"] .entry-modal-date { [data-theme="dark"] .entry-modal-date {
color: #7a8a7a; color: #a2ada2;
} }
[data-theme="dark"] .entry-modal-time { [data-theme="dark"] .entry-modal-time {
@@ -2809,7 +2855,7 @@
[data-theme="dark"] .entry-modal-close { [data-theme="dark"] .entry-modal-close {
background: #252525; background: #252525;
color: #7a8a7a; color: #a2ada2;
} }
[data-theme="dark"] .entry-modal-close:hover { [data-theme="dark"] .entry-modal-close:hover {
@@ -2875,11 +2921,11 @@
color: #e8f5e8; color: #e8f5e8;
} }
[data-theme="dark"] .delete-confirm-body { [data-theme="dark"] .delete-confirm-body {
color: #7a8a7a; color: #a2ada2;
} }
[data-theme="dark"] .delete-confirm-cancel { [data-theme="dark"] .delete-confirm-cancel {
background: #252525; background: #252525;
color: #b0b8b0; color: #c8cdc8;
} }
[data-theme="dark"] .delete-confirm-cancel:hover:not(:disabled) { [data-theme="dark"] .delete-confirm-cancel:hover:not(:disabled) {
background: #303030; background: #303030;
@@ -2895,7 +2941,7 @@
} }
[data-theme="dark"] .confirm-modal { [data-theme="dark"] .confirm-modal {
background: var(--color-surface); background: #1e1e1e;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5); box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
} }
@@ -2904,11 +2950,11 @@
} }
[data-theme="dark"] .confirm-modal-desc { [data-theme="dark"] .confirm-modal-desc {
color: #7a8a7a; color: #a2ada2;
} }
[data-theme="dark"] .confirm-modal-label { [data-theme="dark"] .confirm-modal-label {
color: #b0b8b0; color: #c8cdc8;
} }
[data-theme="dark"] .confirm-modal-input { [data-theme="dark"] .confirm-modal-input {
@@ -2924,7 +2970,7 @@
[data-theme="dark"] .confirm-modal-cancel { [data-theme="dark"] .confirm-modal-cancel {
background: #252525; background: #252525;
color: #9ca3af; color: #babfc7;
} }
[data-theme="dark"] .confirm-modal-cancel:hover:not(:disabled) { [data-theme="dark"] .confirm-modal-cancel:hover:not(:disabled) {
@@ -3021,7 +3067,7 @@
font-size: 0.7rem; font-size: 0.7rem;
font-weight: 700; font-weight: 700;
font-family: inherit; font-family: inherit;
color: var(--color-text-muted, #6b7280); color: var(--color-text-muted, #4b505a);
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
cursor: pointer; cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s; transition: background 0.15s, color 0.15s, border-color 0.15s;
@@ -3079,7 +3125,7 @@
} }
.clock-picker__tick { .clock-picker__tick {
stroke: var(--color-text-muted, #9ca3af); stroke: var(--color-text-muted, #6d727a);
stroke-width: 1.5; stroke-width: 1.5;
opacity: 0.4; opacity: 0.4;
} }
@@ -3096,7 +3142,7 @@
font-size: 0.7rem; font-size: 0.7rem;
font-weight: 600; font-weight: 600;
font-family: inherit; font-family: inherit;
color: var(--color-text-muted, #6b7280); color: var(--color-text-muted, #4b505a);
padding: 0.25rem 0.75rem; padding: 0.25rem 0.75rem;
cursor: pointer; cursor: pointer;
transition: all 0.15s; transition: all 0.15s;
@@ -3125,7 +3171,7 @@
[data-theme="dark"] .clock-picker__ampm-btn { [data-theme="dark"] .clock-picker__ampm-btn {
background: #2a2a2a; background: #2a2a2a;
color: #9ca3af; color: #babfc7;
} }
[data-theme="dark"] .clock-picker__ampm-btn--active { [data-theme="dark"] .clock-picker__ampm-btn--active {
@@ -3168,7 +3214,7 @@
[data-theme="dark"] .clock-picker__mode-btn { [data-theme="dark"] .clock-picker__mode-btn {
border-color: #333; border-color: #333;
color: #9ca3af; color: #babfc7;
} }
/* ============================ /* ============================
@@ -3187,14 +3233,12 @@
[data-theme="liquid-glass"] .calendar-card, [data-theme="liquid-glass"] .calendar-card,
[data-theme="liquid-glass"] .entry-card, [data-theme="liquid-glass"] .entry-card,
[data-theme="liquid-glass"] .entry-modal, [data-theme="liquid-glass"] .entry-modal,
[data-theme="liquid-glass"] .confirm-modal,
[data-theme="liquid-glass"] .settings-profile, [data-theme="liquid-glass"] .settings-profile,
[data-theme="liquid-glass"] .settings-card, [data-theme="liquid-glass"] .settings-card,
[data-theme="liquid-glass"] .settings-tutorial-btn, [data-theme="liquid-glass"] .settings-tutorial-btn,
[data-theme="liquid-glass"] .settings-clear-btn, [data-theme="liquid-glass"] .settings-clear-btn,
[data-theme="liquid-glass"] .settings-signout-btn, [data-theme="liquid-glass"] .settings-signout-btn,
[data-theme="liquid-glass"] .bottom-nav, [data-theme="liquid-glass"] .bottom-nav {
[data-theme="liquid-glass"] .lp__form {
background: var(--glass-bg); background: var(--glass-bg);
backdrop-filter: var(--glass-blur); backdrop-filter: var(--glass-blur);
-webkit-backdrop-filter: var(--glass-blur); -webkit-backdrop-filter: var(--glass-blur);
@@ -3250,7 +3294,7 @@
[data-theme="liquid-glass"] .journal-title-input::placeholder, [data-theme="liquid-glass"] .journal-title-input::placeholder,
[data-theme="liquid-glass"] .journal-entry-textarea::placeholder { [data-theme="liquid-glass"] .journal-entry-textarea::placeholder {
color: rgba(30, 41, 59, 0.45); color: rgba(30, 41, 59, 0.65);
} }
[data-theme="liquid-glass"] .journal-title-input { [data-theme="liquid-glass"] .journal-title-input {
@@ -3338,7 +3382,7 @@
color: #334155; color: #334155;
} }
[data-theme="liquid-glass"] .settings-enc { [data-theme="liquid-glass"] .settings-enc {
color: rgba(15, 23, 42, 0.45); color: rgba(15, 23, 42, 0.65);
} }
[data-theme="liquid-glass"] .settings-edit-btn { [data-theme="liquid-glass"] .settings-edit-btn {
background: rgba(34, 197, 94, 0.2); background: rgba(34, 197, 94, 0.2);
@@ -3410,6 +3454,14 @@
-webkit-backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
} }
/* -- Modal boxes always opaque -- */
[data-theme="liquid-glass"] .confirm-modal,
[data-theme="liquid-glass"] .bg-modal {
background: #ffffff;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
/* -- Bottom nav -- */ /* -- Bottom nav -- */
[data-theme="liquid-glass"] .bottom-nav-btn { [data-theme="liquid-glass"] .bottom-nav-btn {
color: #475569; color: #475569;
@@ -3467,6 +3519,21 @@
box-shadow: 0 1px 8px rgba(74, 222, 128, 0.1); box-shadow: 0 1px 8px rgba(74, 222, 128, 0.1);
} }
/* Login page is always light — force button to light style */
[data-theme="dark"] .lp .google-sign-in-btn,
[data-theme="liquid-glass"] .lp .google-sign-in-btn {
background: #fff;
border-color: #dadce0;
color: #3c4043;
}
[data-theme="dark"] .lp .google-sign-in-btn:hover:not(:disabled),
[data-theme="liquid-glass"] .lp .google-sign-in-btn:hover:not(:disabled) {
background: #f8f9fa;
border-color: #dadce0;
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.1);
}
/* ============================ /* ============================
WELCOME MODAL WELCOME MODAL
============================ */ ============================ */
@@ -3537,7 +3604,7 @@
font-family: "Sniglet", system-ui; font-family: "Sniglet", system-ui;
font-size: 0.9rem; font-size: 0.9rem;
line-height: 1.6; line-height: 1.6;
color: var(--color-text-muted, #6b7280); color: var(--color-text-muted, #4b505a);
margin: 0 0 1.75rem; margin: 0 0 1.75rem;
} }
@@ -3569,7 +3636,7 @@
.welcome-modal-skip { .welcome-modal-skip {
font-family: "Sniglet", system-ui; font-family: "Sniglet", system-ui;
font-size: 0.8125rem; font-size: 0.8125rem;
color: var(--color-text-muted, #6b7280); color: var(--color-text-muted, #4b505a);
background: none; background: none;
border: none; border: none;
cursor: pointer; cursor: pointer;
@@ -3602,13 +3669,13 @@
font-family: "Sniglet", system-ui !important; font-family: "Sniglet", system-ui !important;
font-size: 0.85rem !important; font-size: 0.85rem !important;
line-height: 1.55 !important; line-height: 1.55 !important;
color: var(--color-text-muted, #6b7280) !important; color: var(--color-text-muted, #4b505a) !important;
} }
.gj-tour-popover .driver-popover-progress-text { .gj-tour-popover .driver-popover-progress-text {
font-family: "Sniglet", system-ui !important; font-family: "Sniglet", system-ui !important;
font-size: 0.75rem !important; font-size: 0.75rem !important;
color: var(--color-text-muted, #6b7280) !important; color: var(--color-text-muted, #4b505a) !important;
} }
.gj-tour-popover .driver-popover-navigation-btns button { .gj-tour-popover .driver-popover-navigation-btns button {
@@ -3633,13 +3700,13 @@
} }
.gj-tour-popover .driver-popover-prev-btn { .gj-tour-popover .driver-popover-prev-btn {
color: var(--color-text-muted, #6b7280) !important; color: var(--color-text-muted, #4b505a) !important;
border: 1px solid var(--color-border, #d4e8d4) !important; border: 1px solid var(--color-border, #d4e8d4) !important;
background: transparent !important; background: transparent !important;
} }
.gj-tour-popover .driver-popover-close-btn { .gj-tour-popover .driver-popover-close-btn {
color: var(--color-text-muted, #6b7280) !important; color: var(--color-text-muted, #4b505a) !important;
outline: none !important; outline: none !important;
box-shadow: none !important; box-shadow: none !important;
border: none !important; border: none !important;
@@ -3670,16 +3737,16 @@
} }
[data-theme="dark"] .gj-tour-popover .driver-popover-description { [data-theme="dark"] .gj-tour-popover .driver-popover-description {
color: #7a8a7a !important; color: #a2ada2 !important;
} }
[data-theme="dark"] .gj-tour-popover .driver-popover-prev-btn { [data-theme="dark"] .gj-tour-popover .driver-popover-prev-btn {
border-color: rgba(74, 222, 128, 0.15) !important; border-color: rgba(74, 222, 128, 0.15) !important;
color: #7a8a7a !important; color: #a2ada2 !important;
} }
[data-theme="dark"] .gj-tour-popover .driver-popover-close-btn { [data-theme="dark"] .gj-tour-popover .driver-popover-close-btn {
color: #7a8a7a !important; color: #a2ada2 !important;
outline: none !important; outline: none !important;
box-shadow: none !important; box-shadow: none !important;
border: none !important; border: none !important;
@@ -3764,7 +3831,7 @@
.static-page__updated { .static-page__updated {
font-size: 0.875rem; font-size: 0.875rem;
color: #6b7280; color: #4b505a;
margin-bottom: 1.75rem !important; margin-bottom: 1.75rem !important;
} }
@@ -3795,7 +3862,7 @@
} }
.static-page__footer span { .static-page__footer span {
color: #9ca3af; color: #6d727a;
} }
/* ============================ /* ============================
@@ -3818,7 +3885,7 @@ body.gj-has-bg .settings-page {
============================ */ ============================ */
.bg-modal { .bg-modal {
background: var(--color-surface, #fff); background: #ffffff;
border-radius: 20px; border-radius: 20px;
padding: 1.5rem; padding: 1.5rem;
width: min(440px, calc(100vw - 2rem)); width: min(440px, calc(100vw - 2rem));
@@ -3861,7 +3928,7 @@ body.gj-has-bg .settings-page {
.bg-grid-add { .bg-grid-add {
border: 2px dashed var(--color-border, #d4e8d4); border: 2px dashed var(--color-border, #d4e8d4);
background: var(--color-accent-light, #dcfce7); background: var(--color-accent-light, #dcfce7);
color: var(--color-text-muted, #6b7280); color: var(--color-text-muted, #4b505a);
} }
.bg-grid-add:hover:not(:disabled) { .bg-grid-add:hover:not(:disabled) {
@@ -3938,7 +4005,7 @@ body.gj-has-bg .settings-page {
border-radius: 10px; border-radius: 10px;
border: 1.5px solid var(--color-border, #d4e8d4); border: 1.5px solid var(--color-border, #d4e8d4);
background: transparent; background: transparent;
color: var(--color-text-muted, #6b7280); color: var(--color-text-muted, #4b505a);
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
@@ -4010,7 +4077,7 @@ body.gj-has-bg .settings-page {
.cropper-cancel-btn { .cropper-cancel-btn {
background: none; background: none;
border: none; border: none;
color: #9ca3af; color: #6d727a;
font-size: 0.9rem; font-size: 0.9rem;
cursor: pointer; cursor: pointer;
padding: 0.3rem 0.5rem; padding: 0.3rem 0.5rem;
@@ -4118,7 +4185,7 @@ body.gj-has-bg .settings-page {
[data-theme="dark"] .bg-grid-add { [data-theme="dark"] .bg-grid-add {
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.12); border-color: rgba(255, 255, 255, 0.12);
color: #9ca3af; color: #babfc7;
} }
[data-theme="dark"] .bg-grid-add:hover:not(:disabled) { [data-theme="dark"] .bg-grid-add:hover:not(:disabled) {
@@ -4133,7 +4200,7 @@ body.gj-has-bg .settings-page {
[data-theme="dark"] .bg-default-btn { [data-theme="dark"] .bg-default-btn {
border-color: rgba(255, 255, 255, 0.12); border-color: rgba(255, 255, 255, 0.12);
color: #9ca3af; color: #babfc7;
} }
[data-theme="dark"] .bg-default-btn:hover:not(:disabled) { [data-theme="dark"] .bg-default-btn:hover:not(:disabled) {

View File

@@ -238,17 +238,24 @@ export default function ClockTimePicker({ value, onChange, disabled }: Props) {
<circle cx={handTip.x} cy={handTip.y} r={TIP_RADIUS} className="clock-picker__hand-tip" /> <circle cx={handTip.x} cy={handTip.y} r={TIP_RADIUS} className="clock-picker__hand-tip" />
{mode === 'hours' && hourPositions.map(({ h, x, y }) => ( {mode === 'hours' && hourPositions.map(({ h, x, y }) => (
h === selectedNum ? null :
<text key={h} x={x} y={y} textAnchor="middle" dominantBaseline="central" <text key={h} x={x} y={y} textAnchor="middle" dominantBaseline="central"
className={`clock-picker__num${h === selectedNum ? ' clock-picker__num--selected' : ''}`} className="clock-picker__num"
>{h}</text> >{h}</text>
))} ))}
{mode === 'minutes' && minutePositions.map(({ m, x, y }) => ( {mode === 'minutes' && minutePositions.map(({ m, x, y }) => (
m === selectedNum ? null :
<text key={m} x={x} y={y} textAnchor="middle" dominantBaseline="central" <text key={m} x={x} y={y} textAnchor="middle" dominantBaseline="central"
className={`clock-picker__num${m === selectedNum ? ' clock-picker__num--selected' : ''}`} className="clock-picker__num"
>{m.toString().padStart(2, '0')}</text> >{m.toString().padStart(2, '0')}</text>
))} ))}
{/* Selected number rendered at hand tip so it sits centered in the green circle */}
<text x={handTip.x} y={handTip.y} textAnchor="middle" dominantBaseline="central"
className="clock-picker__num clock-picker__num--selected"
>{mode === 'hours' ? selectedNum : selectedNum.toString().padStart(2, '0')}</text>
{mode === 'minutes' && Array.from({ length: 60 }, (_, i) => { {mode === 'minutes' && Array.from({ length: 60 }, (_, i) => {
if (i % 5 === 0) return null if (i % 5 === 0) return null
const angle = (i / 60) * 360 const angle = (i / 60) * 360

View File

@@ -1,12 +1,14 @@
import { type ReactNode, Suspense, useState, useEffect } from 'react' import { type ReactNode, Suspense, useState, useLayoutEffect } from 'react'
import { Navigate, useLocation } from 'react-router-dom' import { Navigate, useLocation } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { PageLoader } from './PageLoader' import { PageLoader } from './PageLoader'
// Mounts only once Suspense has resolved (chunk is ready). // Mounts only once Suspense has resolved (chunk is ready).
// Signals the parent to hide the loader and reveal content. // useLayoutEffect fires before the browser paints, so setState here causes a
// synchronous re-render — content becomes visible in the same paint with no
// intermediate loader flash for cached chunks.
function ContentReady({ onReady }: { onReady: () => void }) { function ContentReady({ onReady }: { onReady: () => void }) {
useEffect(() => { useLayoutEffect(() => {
onReady() onReady()
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []) }, [])
@@ -19,9 +21,10 @@ export function ProtectedRoute({ children }: Props) {
const { user, loading } = useAuth() const { user, loading } = useAuth()
const location = useLocation() const location = useLocation()
// On page refresh: loading starts true → contentReady=false → loader shows throughout. // Always start false so the loader covers any intermediate render state.
// On in-app navigation: loading is already false → contentReady=true → no loader shown. // For cached chunks, ContentReady's useLayoutEffect fires before the first
const [contentReady, setContentReady] = useState(() => !loading) // paint and flips this synchronously — no visible flash.
const [contentReady, setContentReady] = useState(false)
if (!loading && !user) { if (!loading && !user) {
return <Navigate to="/" state={{ from: location }} replace /> return <Navigate to="/" state={{ from: location }} replace />

View File

@@ -30,6 +30,7 @@ import {
saveEncryptedSecretKey, saveEncryptedSecretKey,
getEncryptedSecretKey, getEncryptedSecretKey,
} from '../lib/crypto' } from '../lib/crypto'
import { REMINDER_TIME_KEY, REMINDER_ENABLED_KEY } from '../hooks/useReminder'
type MongoUser = { type MongoUser = {
id: string id: string
@@ -40,6 +41,11 @@ type MongoUser = {
tutorial?: boolean tutorial?: boolean
backgroundImage?: string | null backgroundImage?: string | null
backgroundImages?: string[] backgroundImages?: string[]
reminder?: {
enabled: boolean
time?: string
timezone?: string
}
} }
type AuthContextValue = { type AuthContextValue = {
@@ -135,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 // Register or fetch user from MongoDB
async function syncUserWithDatabase(authUser: User) { async function syncUserWithDatabase(authUser: User) {
try { try {
@@ -146,14 +164,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// Try to get existing user // Try to get existing user
try { try {
console.log('[Auth] Fetching user by email:', email) // console.log('[Auth] Fetching user by email:', email)
const existingUser = await getUserByEmail(email, token) as MongoUser const existingUser = await getUserByEmail(email, token) as MongoUser
// console.log('[Auth] Found existing user:', existingUser.id)
setUserId(existingUser.id) setUserId(existingUser.id)
setMongoUser(existingUser) setMongoUser(existingUser)
syncReminderFromDb(existingUser)
} catch (error) { } catch (error) {
console.warn('[Auth] User not found, registering...', error) console.warn('[Auth] User not found, registering...', error)
// User doesn't exist, register them
const newUser = await registerUser( const newUser = await registerUser(
{ {
email, email,
@@ -162,9 +179,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, },
token token
) as MongoUser ) as MongoUser
console.log('[Auth] Registered new user:', newUser.id) // console.log('[Auth] Registered new user:', newUser.id)
setUserId(newUser.id) setUserId(newUser.id)
setMongoUser(newUser) setMongoUser(newUser)
syncReminderFromDb(newUser)
} }
} catch (error) { } catch (error) {
console.error('[Auth] Error syncing user with database:', error) console.error('[Auth] Error syncing user with database:', error)
@@ -173,11 +191,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
} }
useEffect(() => { useEffect(() => {
// Handle returning from a redirect sign-in (mobile flow) // Handle returning from a redirect sign-in (Safari / iOS / Android WebViews)
getRedirectResult(auth).catch((error) => { getRedirectResult(auth).catch((error) => {
console.error('[Auth] Redirect sign-in error:', error) console.error('[Auth] Redirect sign-in error:', error)
setAuthError(error instanceof Error ? error.message : 'Sign-in failed') setAuthError(error instanceof Error ? error.message : 'Sign-in failed')
}) })
// onAuthStateChanged below handles the successful redirect result automatically
const unsubscribe = onAuthStateChanged(auth, async (u) => { const unsubscribe = onAuthStateChanged(auth, async (u) => {
setUser(u) setUser(u)
@@ -200,6 +219,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
async function signInWithGoogle() { async function signInWithGoogle() {
setAuthError(null) setAuthError(null)
await setPersistence(auth, browserLocalPersistence) await setPersistence(auth, browserLocalPersistence)
// Safari blocks cross-origin storage in popups (ITP), so use redirect flow
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent)
if (isSafari) {
await signInWithRedirect(auth, googleProvider)
return
}
try { try {
await signInWithPopup(auth, googleProvider) await signInWithPopup(auth, googleProvider)
} catch (err: unknown) { } catch (err: unknown) {
@@ -226,13 +253,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
} }
async function signOut() { async function signOut() {
// Clear secret key from memory
setSecretKey(null) setSecretKey(null)
setMongoUser(null) setMongoUser(null)
// Clear pending tour step (session state)
localStorage.removeItem('gj-tour-pending-step') localStorage.removeItem('gj-tour-pending-step')
// Keep device key and encrypted key for next login localStorage.removeItem(REMINDER_TIME_KEY)
// Do NOT clear localStorage or IndexedDB localStorage.removeItem(REMINDER_ENABLED_KEY)
await firebaseSignOut(auth) await firebaseSignOut(auth)
setUserId(null) setUserId(null)
} }

View File

@@ -29,11 +29,21 @@ export function isReminderEnabled(): boolean {
/** Get FCM token using the existing sw.js (which includes Firebase messaging). */ /** Get FCM token using the existing sw.js (which includes Firebase messaging). */
async function getFcmToken(): Promise<string | null> { async function getFcmToken(): Promise<string | null> {
const messaging = await messagingPromise const messaging = await messagingPromise
if (!messaging) return null if (!messaging) {
console.warn('[FCM] Firebase Messaging not supported in this browser')
return null
}
// Use the already-registered sw.js — no second SW needed
const swReg = await navigator.serviceWorker.ready const swReg = await navigator.serviceWorker.ready
return getToken(messaging, { vapidKey: VAPID_KEY, serviceWorkerRegistration: swReg }) // 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
} }
/** /**
@@ -64,16 +74,21 @@ export async function enableReminder(
} }
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
// console.log('[FCM] Saving token and reminder settings:', { timeStr, timezone })
await saveFcmToken(userId, fcmToken, authToken) await saveFcmToken(userId, fcmToken, authToken)
// console.log('[FCM] Token saved to backend')
await saveReminderSettings(userId, { time: timeStr, enabled: true, timezone }, authToken) 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_TIME_KEY, timeStr)
localStorage.setItem(REMINDER_ENABLED_KEY, 'true') localStorage.setItem(REMINDER_ENABLED_KEY, 'true')
return null return null
} catch (err) { } catch (err) {
console.error('FCM reminder setup failed', err) const msg = err instanceof Error ? err.message : String(err)
return 'Failed to set up push notification. Please try again.' console.error('[FCM] Reminder setup failed:', msg)
return `Failed to set up reminder: ${msg}`
} }
} }
@@ -98,16 +113,21 @@ export async function listenForegroundMessages(): Promise<() => void> {
const messaging = await messagingPromise const messaging = await messagingPromise
if (!messaging) return () => {} if (!messaging) return () => {}
// console.log('[FCM] Foreground message listener registered')
const unsubscribe = onMessage(messaging, (payload) => { const unsubscribe = onMessage(messaging, (payload) => {
// console.log('[FCM] Foreground message received:', payload)
const title = payload.notification?.title || 'Grateful Journal 🌱' const title = payload.notification?.title || 'Grateful Journal 🌱'
const body = payload.notification?.body || "You haven't written today yet." const body = payload.notification?.body || "You haven't written today yet."
if (Notification.permission === 'granted') { if (Notification.permission !== 'granted') {
console.warn('[FCM] Notification permission not granted — cannot show notification')
return
}
new Notification(title, { new Notification(title, {
body, body,
icon: '/web-app-manifest-192x192.png', icon: '/web-app-manifest-192x192.png',
tag: 'gj-daily-reminder', tag: 'gj-daily-reminder',
}) })
}
}) })
return unsubscribe return unsubscribe

View File

@@ -28,7 +28,7 @@ input, textarea {
--color-surface: rgb(255 255 255 / var(--card-bg-opacity)); --color-surface: rgb(255 255 255 / var(--card-bg-opacity));
--color-accent-light: #dcfce7; --color-accent-light: #dcfce7;
--color-text: #1a1a1a; --color-text: #1a1a1a;
--color-text-muted: #6b7280; --color-text-muted: #4b505a;
--color-border: #d4e8d4; --color-border: #d4e8d4;
color: var(--color-text); color: var(--color-text);
@@ -85,7 +85,7 @@ button:focus-visible {
--color-surface: rgb(26 26 26 / var(--card-bg-opacity)); --color-surface: rgb(26 26 26 / var(--card-bg-opacity));
--color-accent-light: rgba(74, 222, 128, 0.12); --color-accent-light: rgba(74, 222, 128, 0.12);
--color-text: #e8f5e8; --color-text: #e8f5e8;
--color-text-muted: #7a8a7a; --color-text-muted: #a2ada2;
--color-border: rgba(74, 222, 128, 0.12); --color-border: rgba(74, 222, 128, 0.12);
color: var(--color-text); color: var(--color-text);

View File

@@ -15,7 +15,9 @@ if ('serviceWorker' in navigator) {
} }
// Show FCM notifications when app is open in foreground // Show FCM notifications when app is open in foreground
listenForegroundMessages() listenForegroundMessages().catch((err) => {
console.error('[FCM] Failed to set up foreground message listener:', err)
})
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>

View File

@@ -31,7 +31,7 @@ const SAVE_LEAVES = [
] ]
export default function HomePage() { export default function HomePage() {
const { user, userId, mongoUser, secretKey, loading } = useAuth() const { user, userId, mongoUser, secretKey, loading, refreshMongoUser } = useAuth()
const navigate = useNavigate() const navigate = useNavigate()
const [entry, setEntry] = useState('') const [entry, setEntry] = useState('')
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
@@ -63,7 +63,8 @@ export default function HomePage() {
async function markTutorialDone() { async function markTutorialDone() {
if (!user || !userId) return if (!user || !userId) return
const token = await user.getIdToken() const token = await user.getIdToken()
updateUserProfile(userId, { tutorial: true }, token).catch(console.error) await updateUserProfile(userId, { tutorial: true }, token).catch(console.error)
refreshMongoUser()
} }
const handleStartTour = () => { const handleStartTour = () => {

View File

@@ -19,7 +19,9 @@ export default function LoginPage() {
useEffect(() => { useEffect(() => {
if (loading) return if (loading) return
if (user) navigate('/write', { replace: true }) if (user) {
import('./HomePage').then(() => navigate('/write', { replace: true }))
}
}, [user, loading, navigate]) }, [user, loading, navigate])
async function handleGoogleSignIn() { async function handleGoogleSignIn() {
@@ -38,7 +40,7 @@ export default function LoginPage() {
// Without the `user` check here, the login form flashes for one frame // Without the `user` check here, the login form flashes for one frame
// between loading→false and the useEffect redirect. // between loading→false and the useEffect redirect.
if (loading || signingIn || user) { if (loading || signingIn || user) {
return <PageLoader /> return <div style={{ background: '#eef6ee', height: '100dvh' }}><PageLoader transparent /></div>
} }
return ( return (

View File

@@ -10,7 +10,7 @@ import { PageLoader } from '../components/PageLoader'
import { usePWAInstall } from '../hooks/usePWAInstall' import { usePWAInstall } from '../hooks/usePWAInstall'
import { import {
getSavedReminderTime, isReminderEnabled, getSavedReminderTime, isReminderEnabled,
enableReminder, disableReminder, reenableReminder, enableReminder, disableReminder,
} from '../hooks/useReminder' } from '../hooks/useReminder'
import ClockTimePicker from '../components/ClockTimePicker' import ClockTimePicker from '../components/ClockTimePicker'
@@ -311,33 +311,6 @@ export default function SettingsPage() {
} }
} }
// @ts-ignore — intentionally unused, reminder is disabled (coming soon)
const handleReminderToggle = async () => {
if (!user || !userId) return
if (!reminderTime) {
handleOpenReminderModal()
return
}
if (reminderEnabled) {
const authToken = await user.getIdToken()
await disableReminder(userId, authToken)
setReminderEnabled(false)
} else {
setReminderSaving(true)
const authToken = await user.getIdToken()
const error = await reenableReminder(userId, authToken)
setReminderSaving(false)
if (error) {
setReminderError(error)
setShowReminderModal(true)
} else {
setReminderEnabled(true)
setMessage({ type: 'success', text: 'Reminder enabled!' })
setTimeout(() => setMessage(null), 2000)
}
}
}
const handleSignOut = async () => { const handleSignOut = async () => {
try { try {
await signOut() await signOut()
@@ -375,6 +348,7 @@ export default function SettingsPage() {
</div> </div>
<div className="settings-profile-info"> <div className="settings-profile-info">
<h2 className="settings-profile-name">{displayName}</h2> <h2 className="settings-profile-name">{displayName}</h2>
{user?.email && <p className="settings-profile-email">{user.email}</p>}
</div> </div>
<button type="button" className="settings-edit-btn" onClick={openEditModal} title="Edit profile"> <button type="button" className="settings-edit-btn" onClick={openEditModal} title="Edit profile">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
@@ -385,11 +359,11 @@ export default function SettingsPage() {
</div> </div>
{/* Privacy & Security */} {/* Privacy & Security */}
<section className="settings-section"> {/* <section className="settings-section"> */}
<h3 className="settings-section-title">PRIVACY & SECURITY</h3> {/* <h3 className="settings-section-title">PRIVACY & SECURITY</h3> */}
<div className="settings-card"> {/* <div className="settings-card"> */}
{/* Passcode Lock — disabled for now, toggle is non-functional */} {/* Passcode Lock — disabled for now, toggle is non-functional */}
<div className="settings-item" style={{ opacity: 0.5 }}> {/* <div className="settings-item" style={{ opacity: 0.5 }}>
<div className="settings-item-icon settings-item-icon-green"> <div className="settings-item-icon settings-item-icon-green">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect> <rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
@@ -409,7 +383,7 @@ export default function SettingsPage() {
/> />
<span className="settings-toggle-slider" style={{ cursor: 'not-allowed' }}></span> <span className="settings-toggle-slider" style={{ cursor: 'not-allowed' }}></span>
</label> </label>
</div> </div> */}
{/* Face ID — commented out for future use */} {/* Face ID — commented out for future use */}
{/* {/*
@@ -440,8 +414,8 @@ export default function SettingsPage() {
</label> </label>
</div> </div>
*/} */}
</div> {/* </div> */}
</section> {/* </section> */}
{/* App */} {/* App */}
<section className="settings-section"> <section className="settings-section">
@@ -477,8 +451,12 @@ export default function SettingsPage() {
<div className="settings-divider"></div> <div className="settings-divider"></div>
{/* Daily Reminder — disabled for now, logic preserved */} {/* Daily Reminder */}
<div className="settings-item" style={{ opacity: 0.5 }}> <button
type="button"
className="settings-item settings-item-button"
onClick={handleOpenReminderModal}
>
<div className="settings-item-icon settings-item-icon-orange"> <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"> <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="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
@@ -487,24 +465,20 @@ export default function SettingsPage() {
</div> </div>
<div className="settings-item-content"> <div className="settings-item-content">
<h4 className="settings-item-title">Daily Reminder</h4> <h4 className="settings-item-title">Daily Reminder</h4>
<p className="settings-item-subtitle">Coming soon</p> <p className="settings-item-subtitle">
</div> {reminderEnabled && reminderTime ? `Set for ${reminderTime}` : 'Set a daily reminder' }
<label className="settings-toggle"> </p>
<input
type="checkbox"
checked={false}
disabled
readOnly
/>
<span className="settings-toggle-slider" style={{ cursor: 'not-allowed' }}></span>
</label>
</div> </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> </div>
</section> </section>
{/* Data & Look */} {/* Data & Look */}
<section className="settings-section"> <section className="settings-section">
<h3 className="settings-section-title">DATA & LOOK</h3> <h3 className="settings-section-title">CUSTOMIZATION</h3>
<div className="settings-card"> <div className="settings-card">
{/* Export Journal — commented out for future use */} {/* Export Journal — commented out for future use */}
{/* {/*
@@ -607,11 +581,11 @@ export default function SettingsPage() {
{/* Clear Data */} {/* Clear Data */}
<button type="button" className="settings-clear-btn" onClick={handleClearData}> <button type="button" className="settings-clear-btn" onClick={handleClearData}>
<span>Clear All Data</span>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6"></polyline> <polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path> <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg> </svg>
<span>Clear All Data</span>
</button> </button>
{/* Sign Out */} {/* Sign Out */}
@@ -677,7 +651,6 @@ export default function SettingsPage() {
<div className="confirm-modal" onClick={(e) => e.stopPropagation()}> <div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
<h3 className="edit-modal-title">Edit Profile</h3> <h3 className="edit-modal-title">Edit Profile</h3>
<label className="edit-modal-avatar" style={{ cursor: 'pointer' }}>
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
@@ -685,6 +658,7 @@ export default function SettingsPage() {
style={{ display: 'none' }} style={{ display: 'none' }}
onChange={handlePhotoSelect} onChange={handlePhotoSelect}
/> />
<div className="edit-modal-avatar">
{editPhotoPreview ? ( {editPhotoPreview ? (
<img src={editPhotoPreview} alt="Preview" className="edit-modal-avatar-img" <img src={editPhotoPreview} alt="Preview" className="edit-modal-avatar-img"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }} onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
@@ -694,13 +668,15 @@ export default function SettingsPage() {
{editName.charAt(0).toUpperCase() || 'U'} {editName.charAt(0).toUpperCase() || 'U'}
</div> </div>
)} )}
<div className="edit-modal-avatar-overlay">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
<circle cx="12" cy="13" r="4" />
</svg>
</div> </div>
</label> <div className="edit-modal-photo-actions">
<button
type="button"
className="edit-modal-change-photo"
onClick={() => fileInputRef.current?.click()}
>
Change photo
</button>
{editPhotoPreview && ( {editPhotoPreview && (
<button <button
type="button" type="button"
@@ -710,6 +686,7 @@ export default function SettingsPage() {
Remove photo Remove photo
</button> </button>
)} )}
</div>
<label className="confirm-modal-label" style={{ marginTop: '1rem' }}>Display Name</label> <label className="confirm-modal-label" style={{ marginTop: '1rem' }}>Display Name</label>
<input <input
@@ -999,6 +976,35 @@ export default function SettingsPage() {
{reminderSaving ? 'Saving…' : 'Save'} {reminderSaving ? 'Saving…' : 'Save'}
</button> </button>
</div> </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>
</div> </div>
)} )}