Compare commits

...

6 Commits

Author SHA1 Message Date
19dcd73b29 final notif changes 2026-04-14 11:10:44 +05:30
a1ac8e7933 Create liquidglass.md 2026-04-13 15:18:03 +05:30
4d3a0ca1bd Update index.css 2026-04-13 15:07:36 +05:30
937a98c58d opacity change 2026-04-13 15:07:14 +05:30
1353dfc69d added bg feature 2026-04-13 14:49:12 +05:30
34254f94f9 seo improvement and updated notifs 2026-04-13 12:27:30 +05:30
37 changed files with 2504 additions and 106 deletions

View File

@@ -15,7 +15,15 @@
"Bash(find /Users/jeet/Desktop/Jio/grateful-journal/src -type f -name *.ts -o -name *.tsx)", "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.*)", "Bash(ls -la /Users/jeet/Desktop/Jio/grateful-journal/*.config.*)",
"mcp__ide__getDiagnostics", "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\\)\\)\")"
] ]
} }
} }

15
TODO.md
View File

@@ -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

View File

@@ -8,3 +8,8 @@ FRONTEND_URL=http://localhost:8000
# MONGODB_URI=mongodb://mongo:27017 # MONGODB_URI=mongodb://mongo:27017
# ENVIRONMENT=production # 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=

View File

@@ -8,6 +8,8 @@ class Settings(BaseSettings):
api_port: int = 8001 api_port: int = 8001
environment: str = "development" environment: str = "development"
frontend_url: str = "http://localhost:8000" 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( model_config = SettingsConfigDict(
env_file=".env", env_file=".env",

View File

@@ -1,19 +1,26 @@
from fastapi import FastAPI, HTTPException, Depends from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from db import MongoDB, get_database from db import MongoDB
from config import get_settings from config import get_settings
from routers import entries, users from routers import entries, users
from routers import notifications
from scheduler import start_scheduler
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
settings = get_settings() settings = get_settings()
_scheduler = None
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# Startup # Startup
MongoDB.connect_db() MongoDB.connect_db()
global _scheduler
_scheduler = start_scheduler()
yield yield
# Shutdown # Shutdown
if _scheduler:
_scheduler.shutdown(wait=False)
MongoDB.close_db() MongoDB.close_db()
app = FastAPI( app = FastAPI(
@@ -43,6 +50,7 @@ app.add_middleware(
# Include routers # Include routers
app.include_router(users.router, prefix="/api/users", tags=["users"]) app.include_router(users.router, prefix="/api/users", tags=["users"])
app.include_router(entries.router, prefix="/api/entries", tags=["entries"]) app.include_router(entries.router, prefix="/api/entries", tags=["entries"])
app.include_router(notifications.router, prefix="/api/notifications", tags=["notifications"])
@app.get("/health") @app.get("/health")

View File

@@ -39,6 +39,8 @@ class UserUpdate(BaseModel):
photoURL: Optional[str] = None photoURL: Optional[str] = None
theme: Optional[str] = None theme: Optional[str] = None
tutorial: Optional[bool] = None tutorial: Optional[bool] = None
backgroundImage: Optional[str] = None
backgroundImages: Optional[List[str]] = None
class Config: class Config:
json_schema_extra = { json_schema_extra = {

View File

@@ -5,6 +5,9 @@ pydantic>=2.5.0
python-dotenv==1.0.0 python-dotenv==1.0.0
pydantic-settings>=2.1.0 pydantic-settings>=2.1.0
python-multipart==0.0.6 python-multipart==0.0.6
firebase-admin>=6.5.0
apscheduler>=3.10.4
pytz>=2024.1
# Testing # Testing
pytest>=7.4.0 pytest>=7.4.0

View 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"}

View File

@@ -52,6 +52,8 @@ async def register_user(user_data: UserCreate):
"displayName": user["displayName"], "displayName": user["displayName"],
"photoURL": user.get("photoURL"), "photoURL": user.get("photoURL"),
"theme": user.get("theme", "light"), "theme": user.get("theme", "light"),
"backgroundImage": user.get("backgroundImage"),
"backgroundImages": user.get("backgroundImages", []),
"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"
@@ -79,6 +81,8 @@ async def get_user_by_email(email: str):
"displayName": user.get("displayName"), "displayName": user.get("displayName"),
"photoURL": user.get("photoURL"), "photoURL": user.get("photoURL"),
"theme": user.get("theme", "light"), "theme": user.get("theme", "light"),
"backgroundImage": user.get("backgroundImage"),
"backgroundImages": user.get("backgroundImages", []),
"tutorial": user.get("tutorial"), "tutorial": user.get("tutorial"),
"createdAt": user["createdAt"].isoformat(), "createdAt": user["createdAt"].isoformat(),
"updatedAt": user["updatedAt"].isoformat() "updatedAt": user["updatedAt"].isoformat()
@@ -111,6 +115,8 @@ async def get_user_by_id(user_id: str):
"displayName": user.get("displayName"), "displayName": user.get("displayName"),
"photoURL": user.get("photoURL"), "photoURL": user.get("photoURL"),
"theme": user.get("theme", "light"), "theme": user.get("theme", "light"),
"backgroundImage": user.get("backgroundImage"),
"backgroundImages": user.get("backgroundImages", []),
"createdAt": user["createdAt"].isoformat(), "createdAt": user["createdAt"].isoformat(),
"updatedAt": user["updatedAt"].isoformat() "updatedAt": user["updatedAt"].isoformat()
} }
@@ -152,6 +158,8 @@ async def update_user(user_id: str, user_data: UserUpdate):
"displayName": user.get("displayName"), "displayName": user.get("displayName"),
"photoURL": user.get("photoURL"), "photoURL": user.get("photoURL"),
"theme": user.get("theme", "light"), "theme": user.get("theme", "light"),
"backgroundImage": user.get("backgroundImage"),
"backgroundImages": user.get("backgroundImages", []),
"tutorial": user.get("tutorial"), "tutorial": user.get("tutorial"),
"createdAt": user["createdAt"].isoformat(), "createdAt": user["createdAt"].isoformat(),
"updatedAt": user["updatedAt"].isoformat(), "updatedAt": user["updatedAt"].isoformat(),

179
backend/scheduler.py Normal file
View File

@@ -0,0 +1,179 @@
"""
Daily reminder scheduler.
Runs every minute. For each user with an enabled reminder:
- Converts current UTC time to the user's local timezone
- Checks if the current HH:MM matches their reminder time
- Checks if they already got a notification today (avoids duplicates)
- Checks if they have already written a journal entry today
- If not, sends an FCM push notification to all their registered devices
"""
import json
import logging
from datetime import datetime, timedelta
import pytz
import firebase_admin
from firebase_admin import credentials, messaging
from apscheduler.schedulers.background import BackgroundScheduler
from config import get_settings
from db import get_database
log = logging.getLogger(__name__)
_firebase_initialized = False
def init_firebase():
"""Initialize Firebase Admin SDK once using the service account JSON from env."""
global _firebase_initialized
if _firebase_initialized:
return
settings = get_settings()
if not settings.firebase_service_account_json:
log.warning("FIREBASE_SERVICE_ACCOUNT_JSON not set — push notifications disabled")
return
try:
sa_dict = json.loads(settings.firebase_service_account_json)
cred = credentials.Certificate(sa_dict)
firebase_admin.initialize_app(cred)
_firebase_initialized = True
log.info("Firebase Admin SDK initialized")
except Exception as e:
log.error(f"Failed to initialize Firebase Admin SDK: {e}")
def send_reminder_notifications():
"""Check all users and send reminders where due."""
if not _firebase_initialized:
return
db = get_database()
now_utc = datetime.utcnow().replace(second=0, microsecond=0)
# Find all users with reminder enabled and at least one FCM token
users = db.users.find({
"reminder.enabled": True,
"fcmTokens": {"$exists": True, "$not": {"$size": 0}},
"reminder.time": {"$exists": True},
})
for user in users:
try:
_process_user(db, user, now_utc)
except Exception as e:
log.error(f"Error processing reminder for user {user.get('_id')}: {e}")
def _process_user(db, user: dict, now_utc: datetime):
reminder = user.get("reminder", {})
reminder_time_str = reminder.get("time") # "HH:MM"
timezone_str = reminder.get("timezone", "UTC")
fcm_tokens: list = user.get("fcmTokens", [])
if not reminder_time_str or not fcm_tokens:
return
try:
user_tz = pytz.timezone(timezone_str)
except pytz.UnknownTimeZoneError:
user_tz = pytz.utc
# Current time in user's timezone
now_local = now_utc.replace(tzinfo=pytz.utc).astimezone(user_tz)
current_hm = now_local.strftime("%H:%M")
if current_hm != reminder_time_str:
return # Not the right minute
# Check if already notified today (in user's local date)
today_local_str = now_local.strftime("%Y-%m-%d")
last_notified = reminder.get("lastNotifiedDate", "")
if last_notified == today_local_str:
return # Already sent today
# Check if user has already written today (using createdAt in their timezone)
today_start_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0)
today_start_utc = today_start_local.astimezone(pytz.utc).replace(tzinfo=None)
today_end_utc = today_start_utc + timedelta(days=1)
entry_count = db.entries.count_documents({
"userId": user["_id"],
"createdAt": {"$gte": today_start_utc, "$lt": today_end_utc},
})
if entry_count > 0:
# Already wrote today — mark notified to avoid repeated checks
db.users.update_one(
{"_id": user["_id"]},
{"$set": {"reminder.lastNotifiedDate": today_local_str}}
)
return
# Send FCM notification
_send_push(user["_id"], fcm_tokens, db, today_local_str)
def _send_push(user_id, tokens: list, db, today_local_str: str):
"""Send FCM multicast and prune stale tokens."""
message = messaging.MulticastMessage(
notification=messaging.Notification(
title="Time to journal 🌱",
body="You haven't written today yet. Take a moment to reflect.",
),
tokens=tokens,
android=messaging.AndroidConfig(priority="high"),
apns=messaging.APNSConfig(
payload=messaging.APNSPayload(
aps=messaging.Aps(sound="default")
)
),
webpush=messaging.WebpushConfig(
notification=messaging.WebpushNotification(
icon="/web-app-manifest-192x192.png",
badge="/favicon-96x96.png",
tag="gj-daily-reminder",
)
),
)
response = messaging.send_each_for_multicast(message)
log.info(f"Reminder sent to user {user_id}: {response.success_count} ok, {response.failure_count} failed")
# Remove tokens that are no longer valid
stale_tokens = [
tokens[i] for i, r in enumerate(response.responses)
if not r.success and r.exception and "not-registered" in str(r.exception).lower()
]
if stale_tokens:
db.users.update_one(
{"_id": user_id},
{"$pullAll": {"fcmTokens": stale_tokens}}
)
log.info(f"Removed {len(stale_tokens)} stale FCM tokens for user {user_id}")
# Mark today as notified
db.users.update_one(
{"_id": user_id},
{"$set": {"reminder.lastNotifiedDate": today_local_str}}
)
def start_scheduler() -> BackgroundScheduler:
"""Initialize Firebase and start the minute-by-minute scheduler."""
init_firebase()
scheduler = BackgroundScheduler(timezone="UTC")
scheduler.add_job(
send_reminder_notifications,
trigger="cron",
minute="*", # every minute
id="daily_reminders",
replace_existing=True,
)
scheduler.start()
log.info("Reminder scheduler started")
return scheduler

View File

@@ -30,52 +30,111 @@
<meta property="og:title" content="Grateful Journal — Your Private Gratitude Journal" /> <meta property="og:title" content="Grateful Journal — Your Private Gratitude Journal" />
<meta property="og:description" content="A private, end-to-end encrypted gratitude journal. No feeds, no noise — just you and your thoughts." /> <meta property="og: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" 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 --> <!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Grateful Journal — Your Private Gratitude Journal" /> <meta name="twitter:title" content="Grateful Journal — Your Private Gratitude Journal" />
<meta name="twitter:description" content="A private, end-to-end encrypted gratitude journal. No feeds, no noise — just you and your thoughts." /> <meta name="twitter: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" 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"> <script type="application/ld+json">
[ {
{ "@context": "https://schema.org",
"@context": "https://schema.org", "@type": "WebSite",
"@type": "WebSite", "name": "Grateful Journal",
"name": "Grateful Journal", "url": "https://gratefuljournal.online/",
"url": "https://gratefuljournal.online/" "potentialAction": {
}, "@type": "SearchAction",
{ "target": "https://gratefuljournal.online/?q={search_term_string}",
"@context": "https://schema.org", "query-input": "required name=search_term_string"
"@type": "Organization",
"name": "Grateful Journal",
"url": "https://gratefuljournal.online/",
"logo": "https://gratefuljournal.online/web-app-manifest-512x512.png",
"description": "A private, end-to-end encrypted gratitude journal. No feeds, no noise — just you and your thoughts."
},
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "Grateful Journal",
"url": "https://gratefuljournal.online/",
"description": "A private, end-to-end encrypted gratitude journal. No feeds, no noise — just you and your thoughts.",
"applicationCategory": "LifestyleApplication",
"operatingSystem": "Web, Android, iOS",
"browserRequirements": "Requires JavaScript. Requires HTML5.",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "USD"
},
"featureList": [
"End-to-end encrypted journal entries",
"Daily gratitude prompts",
"Private and secure — no ads, no tracking",
"Works offline as a PWA"
]
} }
] }
</script>
<!-- JSON-LD: Organization -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Organization",
"name": "Grateful Journal",
"url": "https://gratefuljournal.online/",
"logo": {
"@type": "ImageObject",
"url": "https://gratefuljournal.online/web-app-manifest-512x512.png",
"width": 512,
"height": 512
},
"description": "A private, end-to-end encrypted gratitude journal. No feeds, no noise — just you and your thoughts.",
"sameAs": []
}
</script>
<!-- JSON-LD: WebApplication -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "Grateful Journal",
"url": "https://gratefuljournal.online/",
"description": "A private, end-to-end encrypted gratitude journal. No feeds, no noise — just you and your thoughts.",
"applicationCategory": "LifestyleApplication",
"operatingSystem": "Web, Android, iOS",
"browserRequirements": "Requires JavaScript. Requires HTML5.",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "USD"
},
"featureList": "End-to-end encrypted journal entries, Daily gratitude prompts, Private and secure — no ads no tracking, Works offline as a PWA"
}
</script>
<!-- JSON-LD: FAQ -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "Is Grateful Journal free?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Yes, Grateful Journal is completely free to use. There is no subscription or paywall."
}
},
{
"@type": "Question",
"name": "Are my journal entries private?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Yes. Your entries are end-to-end encrypted before leaving your device. Even we cannot read them."
}
},
{
"@type": "Question",
"name": "Does Grateful Journal work offline?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Yes. Grateful Journal is a Progressive Web App (PWA) and can be installed on Android, iOS, and desktop. It works offline once installed."
}
},
{
"@type": "Question",
"name": "Do you sell my data or show ads?",
"acceptedAnswer": {
"@type": "Answer",
"text": "No. We do not sell your data, show ads, or use tracking pixels. Your privacy is the foundation of what we built."
}
}
]
}
</script> </script>
</head> </head>
<body> <body>
@@ -97,7 +156,7 @@
<p><a href="https://gratefuljournal.online/">Get started — it's free</a></p> <p><a href="https://gratefuljournal.online/">Get started — it's free</a></p>
<p> <p>
<a href="/about">About</a> · <a href="/about">About</a> ·
<a href="/privacy">Privacy Policy</a> <a href="/privacypolicy">Privacy Policy</a>
</p> </p>
</main> </main>
</noscript> </noscript>

122
liquidglass.md Normal file
View 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"]`.

View File

@@ -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 { server {
listen 80; listen 80;
server_name _; server_name _;
@@ -5,6 +26,20 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.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/ { location /api/ {
proxy_pass http://backend:8001/api/; proxy_pass http://backend:8001/api/;
proxy_http_version 1.1; proxy_http_version 1.1;

View File

@@ -1,6 +1,4 @@
User-agent: * User-agent: *
Disallow: /write Disallow:
Disallow: /history
Disallow: /settings
Sitemap: https://gratefuljournal.online/sitemap.xml Sitemap: https://gratefuljournal.online/sitemap.xml

View File

@@ -2,19 +2,19 @@
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url> <url>
<loc>https://gratefuljournal.online/</loc> <loc>https://gratefuljournal.online/</loc>
<lastmod>2026-04-08</lastmod> <lastmod>2026-04-13</lastmod>
<changefreq>monthly</changefreq> <changefreq>monthly</changefreq>
<priority>1.0</priority> <priority>1.0</priority>
</url> </url>
<url> <url>
<loc>https://gratefuljournal.online/about</loc> <loc>https://gratefuljournal.online/about</loc>
<lastmod>2026-04-08</lastmod> <lastmod>2026-04-13</lastmod>
<changefreq>monthly</changefreq> <changefreq>monthly</changefreq>
<priority>0.7</priority> <priority>0.7</priority>
</url> </url>
<url> <url>
<loc>https://gratefuljournal.online/privacy</loc> <loc>https://gratefuljournal.online/privacy</loc>
<lastmod>2026-04-08</lastmod> <lastmod>2026-04-13</lastmod>
<changefreq>yearly</changefreq> <changefreq>yearly</changefreq>
<priority>0.4</priority> <priority>0.4</priority>
</url> </url>

View File

@@ -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__' const CACHE = 'gj-__BUILD_TIME__'
self.addEventListener('install', (e) => { self.addEventListener('install', (e) => {
@@ -18,13 +44,22 @@ self.addEventListener('activate', (e) => {
self.clients.claim() self.clients.claim()
}) })
self.addEventListener('fetch', (e) => { self.addEventListener('notificationclick', (e) => {
// Only cache GET requests for same-origin non-API resources e.notification.close()
if ( e.waitUntil(
e.request.method !== 'GET' || self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
e.request.url.includes('/api/') if (clients.length > 0) {
) return 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( e.respondWith(
caches.match(e.request).then((cached) => cached || fetch(e.request)) caches.match(e.request).then((cached) => cached || fetch(e.request))
) )

View File

@@ -94,7 +94,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.75rem; gap: 1.75rem;
background: #fff; background: var(--color-surface);
border-radius: 24px; border-radius: 24px;
border-top: 4px solid #22c55e; border-top: 4px solid #22c55e;
padding: 2rem 1.75rem; padding: 2rem 1.75rem;
@@ -434,7 +434,7 @@
} }
.journal-card { .journal-card {
background: #fff; background: var(--color-surface);
border-radius: 20px; border-radius: 20px;
padding: 1.625rem 1.5rem; padding: 1.625rem 1.5rem;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.07); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.07);
@@ -824,7 +824,7 @@
.bottom-nav { .bottom-nav {
flex-shrink: 0; flex-shrink: 0;
position: relative; /* NOT fixed — lives in the flex column */ position: relative; /* NOT fixed — lives in the flex column */
background: rgba(255, 255, 255, 0.96); background: var(--color-surface);
border-top: 1px solid rgba(0, 0, 0, 0.07); border-top: 1px solid rgba(0, 0, 0, 0.07);
padding: 8px 12px 12px; padding: 8px 12px 12px;
display: flex; display: flex;
@@ -947,7 +947,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: #fff; background: var(--color-surface);
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
border-radius: 50%; border-radius: 50%;
cursor: pointer; cursor: pointer;
@@ -974,7 +974,7 @@
/* Calendar */ /* Calendar */
.calendar-card { .calendar-card {
background: #fff; background: var(--color-surface);
border-radius: 18px; border-radius: 18px;
padding: 1.125rem 1rem; padding: 1.125rem 1rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06); box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
@@ -1106,7 +1106,7 @@
} }
.entry-card { .entry-card {
background: #fff; background: var(--color-surface);
padding: 1rem 1rem 1rem 0.875rem; padding: 1rem 1rem 1rem 0.875rem;
border-radius: 14px; border-radius: 14px;
border-left: 4px solid #22c55e; border-left: 4px solid #22c55e;
@@ -1234,7 +1234,7 @@
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
background: #fff; background: var(--color-surface);
border-radius: 18px; border-radius: 18px;
padding: 1rem 1.125rem; padding: 1rem 1.125rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06); box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
@@ -1415,7 +1415,7 @@
} }
.settings-card { .settings-card {
background: #fff; background: var(--color-surface);
border-radius: 18px; border-radius: 18px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06); box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
overflow: hidden; overflow: hidden;
@@ -1617,7 +1617,7 @@
font-size: 0.9375rem; font-size: 0.9375rem;
font-weight: 600; font-weight: 600;
color: #dc2626; color: #dc2626;
background: #fff; background: var(--color-surface);
border: none; border: none;
border-radius: 14px; border-radius: 14px;
cursor: pointer; cursor: pointer;
@@ -1637,7 +1637,7 @@
font-size: 0.9375rem; font-size: 0.9375rem;
font-weight: 600; font-weight: 600;
color: #6b7280; color: #6b7280;
background: #fff; background: var(--color-surface);
border: none; border: none;
border-radius: 14px; border-radius: 14px;
cursor: pointer; cursor: pointer;
@@ -1706,7 +1706,7 @@
/* Modal panel bottom sheet on mobile, centred card on desktop */ /* Modal panel bottom sheet on mobile, centred card on desktop */
.entry-modal { .entry-modal {
background: #fff; background: var(--color-surface);
border-radius: 20px 20px 0 0; border-radius: 20px 20px 0 0;
width: 100%; width: 100%;
max-height: 85vh; max-height: 85vh;
@@ -1961,7 +1961,7 @@
} }
.confirm-modal { .confirm-modal {
background: #fff; background: var(--color-surface);
border-radius: 20px; border-radius: 20px;
padding: 1.75rem; padding: 1.75rem;
max-width: 380px; max-width: 380px;
@@ -2223,7 +2223,7 @@
/* Desktop dark theme adjustments */ /* Desktop dark theme adjustments */
@media (min-width: 860px) { @media (min-width: 860px) {
[data-theme="dark"] .bottom-nav { [data-theme="dark"] .bottom-nav {
background: #141414; background: var(--color-surface);
border-right-color: rgba(74, 222, 128, 0.1); border-right-color: rgba(74, 222, 128, 0.1);
} }
@@ -2301,7 +2301,7 @@
[data-theme="dark"] .settings-card, [data-theme="dark"] .settings-card,
[data-theme="dark"] .settings-profile, [data-theme="dark"] .settings-profile,
[data-theme="dark"] .entry-card { [data-theme="dark"] .entry-card {
background: #1a1a1a; background: var(--color-surface);
border-color: rgba(74, 222, 128, 0.12); border-color: rgba(74, 222, 128, 0.12);
box-shadow: box-shadow:
0 2px 16px rgba(0, 0, 0, 0.4), 0 2px 16px rgba(0, 0, 0, 0.4),
@@ -2408,7 +2408,7 @@
/* -- Bottom nav -- */ /* -- Bottom nav -- */
[data-theme="dark"] .bottom-nav { [data-theme="dark"] .bottom-nav {
background: rgba(20, 20, 20, 0.97); background: var(--color-surface);
border-top-color: rgba(74, 222, 128, 0.1); border-top-color: rgba(74, 222, 128, 0.1);
} }
@@ -2516,7 +2516,7 @@
/* -- Settings buttons -- */ /* -- Settings buttons -- */
[data-theme="dark"] .settings-tutorial-btn { [data-theme="dark"] .settings-tutorial-btn {
background: #1a1a1a; background: var(--color-surface);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
} }
@@ -2525,7 +2525,7 @@
} }
[data-theme="dark"] .settings-clear-btn { [data-theme="dark"] .settings-clear-btn {
background: #1a1a1a; background: var(--color-surface);
color: #f87171; color: #f87171;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
} }
@@ -2535,7 +2535,7 @@
} }
[data-theme="dark"] .settings-signout-btn { [data-theme="dark"] .settings-signout-btn {
background: #1a1a1a; background: var(--color-surface);
color: #9ca3af; color: #9ca3af;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
} }
@@ -2600,7 +2600,7 @@
/* -- History search button -- */ /* -- History search button -- */
[data-theme="dark"] .history-search-btn { [data-theme="dark"] .history-search-btn {
background: #1a1a1a; background: var(--color-surface);
border-color: #2a2a2a; border-color: #2a2a2a;
color: #7a8a7a; color: #7a8a7a;
} }
@@ -2634,7 +2634,7 @@
/* -- Entry modal -- */ /* -- Entry modal -- */
[data-theme="dark"] .entry-modal { [data-theme="dark"] .entry-modal {
background: #1a1a1a; background: var(--color-surface);
border-top-color: #4ade80; border-top-color: #4ade80;
box-shadow: box-shadow:
0 -4px 32px rgba(0, 0, 0, 0.5), 0 -4px 32px rgba(0, 0, 0, 0.5),
@@ -2688,7 +2688,7 @@
} }
[data-theme="dark"] .delete-confirm-modal { [data-theme="dark"] .delete-confirm-modal {
background: #1a1a1a; background: var(--color-surface);
} }
[data-theme="dark"] .delete-confirm-title { [data-theme="dark"] .delete-confirm-title {
color: #e8f5e8; color: #e8f5e8;
@@ -2714,7 +2714,7 @@
} }
[data-theme="dark"] .confirm-modal { [data-theme="dark"] .confirm-modal {
background: #1a1a1a; background: var(--color-surface);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5); box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
} }
@@ -2750,6 +2750,254 @@
background: #333; background: #333;
} }
/* ── Reminder Modal ────────────────────────────────────────────── */
.reminder-modal-overlay {
background: rgba(0, 0, 0, 0.85);
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.reminder-modal {
background: #ffffff;
max-width: 340px;
width: 100%;
border-radius: 24px;
max-height: none;
overflow: visible;
padding: 1.25rem 1.25rem 1.5rem;
}
[data-theme="dark"] .reminder-modal-overlay {
background: rgba(0, 0, 0, 0.88);
}
[data-theme="dark"] .reminder-modal {
background: #1c1c1e;
}
/* ── Clock Time Picker ─────────────────────────────────────────── */
.clock-picker {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.6rem;
padding: 0.25rem 0;
}
.clock-picker__display {
display: flex;
align-items: center;
gap: 0.25rem;
}
.clock-picker__seg {
background: var(--color-surface-alt, #f3f4f6);
border: none;
border-radius: 10px;
font-size: 2.25rem;
font-weight: 700;
font-family: inherit;
color: var(--color-text, #111827);
width: 3rem;
height: 3.5rem;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.15s, color 0.15s;
line-height: 1;
padding: 0;
}
.clock-picker__seg--active {
background: var(--color-primary, #22c55e);
color: #fff;
}
.clock-picker__colon {
font-size: 2rem;
font-weight: 700;
color: var(--color-text, #111827);
line-height: 1;
padding: 0 0.1rem;
user-select: none;
}
.clock-picker__ampm {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-left: 0.5rem;
}
.clock-picker__ampm-btn {
background: var(--color-surface-alt, #f3f4f6);
border: 1.5px solid transparent;
border-radius: 8px;
font-size: 0.7rem;
font-weight: 700;
font-family: inherit;
color: var(--color-text-muted, #6b7280);
padding: 0.25rem 0.5rem;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
letter-spacing: 0.03em;
}
.clock-picker__ampm-btn--active {
background: var(--color-primary, #22c55e);
color: #fff;
border-color: var(--color-primary, #22c55e);
}
.clock-picker__face {
width: 180px;
height: 180px;
display: block;
border-radius: 50%;
overflow: visible;
}
.clock-picker__bg {
fill: var(--color-surface-alt, #f3f4f6);
}
.clock-picker__sector {
fill: rgba(34, 197, 94, 0.12);
}
.clock-picker__hand {
stroke: var(--color-primary, #22c55e);
stroke-width: 2.5;
stroke-linecap: round;
}
.clock-picker__center-dot {
fill: var(--color-primary, #22c55e);
}
.clock-picker__hand-tip {
fill: var(--color-primary, #22c55e);
}
.clock-picker__num {
font-family: inherit;
font-size: 13px;
font-weight: 500;
fill: var(--color-text, #111827);
user-select: none;
pointer-events: none;
}
.clock-picker__num--selected {
fill: #fff;
font-weight: 700;
}
.clock-picker__tick {
stroke: var(--color-text-muted, #9ca3af);
stroke-width: 1.5;
opacity: 0.4;
}
.clock-picker__modes {
display: flex;
gap: 0.5rem;
}
.clock-picker__mode-btn {
background: none;
border: 1.5px solid var(--color-border, #e5e7eb);
border-radius: 20px;
font-size: 0.7rem;
font-weight: 600;
font-family: inherit;
color: var(--color-text-muted, #6b7280);
padding: 0.25rem 0.75rem;
cursor: pointer;
transition: all 0.15s;
}
.clock-picker__mode-btn--active {
background: var(--color-primary, #22c55e);
border-color: var(--color-primary, #22c55e);
color: #fff;
}
/* Dark mode overrides */
[data-theme="dark"] .clock-picker__seg {
background: #2a2a2a;
color: #f9fafb;
}
[data-theme="dark"] .clock-picker__seg--active {
background: var(--color-primary, #4ade80);
color: #111;
}
[data-theme="dark"] .clock-picker__colon {
color: #f9fafb;
}
[data-theme="dark"] .clock-picker__ampm-btn {
background: #2a2a2a;
color: #9ca3af;
}
[data-theme="dark"] .clock-picker__ampm-btn--active {
background: var(--color-primary, #4ade80);
color: #111;
border-color: var(--color-primary, #4ade80);
}
[data-theme="dark"] .clock-picker__bg {
fill: #2a2a2a;
}
[data-theme="dark"] .clock-picker__sector {
fill: rgba(74, 222, 128, 0.12);
}
[data-theme="dark"] .clock-picker__hand {
stroke: #4ade80;
}
[data-theme="dark"] .clock-picker__center-dot {
fill: #4ade80;
}
[data-theme="dark"] .clock-picker__hand-tip {
fill: #4ade80;
}
[data-theme="dark"] .clock-picker__num {
fill: #d1d5db;
}
[data-theme="dark"] .clock-picker__num--selected {
fill: #111;
}
[data-theme="dark"] .clock-picker__tick {
stroke: #4b5563;
}
[data-theme="dark"] .clock-picker__mode-btn {
border-color: #333;
color: #9ca3af;
}
[data-theme="dark"] .clock-picker__mode-btn--active {
background: #4ade80;
border-color: #4ade80;
color: #111;
}
/* ── End Clock Time Picker ─────────────────────────────────────── */
/* -- Login page — light mode only, no dark theme overrides -- */ /* -- Login page — light mode only, no dark theme overrides -- */
/* -- Google sign-in btn -- */ /* -- Google sign-in btn -- */
@@ -3095,3 +3343,356 @@
.static-page__footer span { .static-page__footer span {
color: #9ca3af; color: #9ca3af;
} }
/* ============================
CUSTOM BACKGROUND IMAGE
============================ */
body.gj-has-bg .home-page,
body.gj-has-bg .history-page,
body.gj-has-bg .settings-page {
background: transparent;
}
[data-theme="dark"] body.gj-has-bg .home-page,
[data-theme="dark"] body.gj-has-bg .history-page,
[data-theme="dark"] body.gj-has-bg .settings-page {
background: transparent;
}
/* ============================
BACKGROUND GALLERY MODAL
============================ */
.bg-modal {
background: var(--color-surface, #fff);
border-radius: 20px;
padding: 1.5rem;
width: min(440px, calc(100vw - 2rem));
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.18);
}
/* 4-column fixed grid — no scroll */
.bg-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.5rem;
margin-bottom: 0.75rem;
}
/* Base tile — aspect-ratio set via inline style to match screen */
.bg-grid-tile {
border-radius: 10px;
border: 2.5px solid transparent;
overflow: hidden;
cursor: pointer;
position: relative;
padding: 0;
width: 100%;
transition: border-color 0.15s, transform 0.12s;
display: flex;
align-items: center;
justify-content: center;
background: none;
}
.bg-grid-tile:hover:not(:disabled) {
transform: scale(1.04);
}
.bg-grid-tile--active {
border-color: var(--color-primary, #22c55e) !important;
}
/* "+" upload tile */
.bg-grid-add {
border: 2px dashed var(--color-border, #d4e8d4);
background: var(--color-accent-light, #dcfce7);
color: var(--color-text-muted, #6b7280);
}
.bg-grid-add:hover:not(:disabled) {
border-color: var(--color-primary, #22c55e);
color: var(--color-primary, #22c55e);
}
/* Empty placeholder tile */
.bg-grid-empty {
background: var(--color-accent-light, #dcfce7);
border: 2px dashed var(--color-border, #d4e8d4);
opacity: 0.4;
cursor: default;
}
/* Thumbnail image fills the tile */
.bg-gallery-thumb-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
pointer-events: none;
}
/* Active checkmark badge */
.bg-gallery-badge {
position: absolute;
bottom: 4px;
right: 4px;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--color-primary, #22c55e);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
/* Wrapper holds tile + delete button as siblings */
.bg-grid-wrapper {
position: relative;
}
.bg-grid-wrapper .bg-grid-tile {
width: 100%;
}
/* Delete (×) button — top-right corner of the image */
.bg-tile-delete {
position: absolute;
top: -6px;
right: -6px;
width: 18px;
height: 18px;
border-radius: 50%;
background: #ef4444;
color: #fff;
border: none;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 2;
box-shadow: 0 1px 4px rgba(0,0,0,0.3);
}
/* Revert to default button */
.bg-default-btn {
width: 100%;
padding: 0.6rem 1rem;
border-radius: 10px;
border: 1.5px solid var(--color-border, #d4e8d4);
background: transparent;
color: var(--color-text-muted, #6b7280);
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
margin-bottom: 0.5rem;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.bg-default-btn:hover:not(:disabled) {
background: var(--color-accent-light, #dcfce7);
border-color: var(--color-primary, #22c55e);
color: var(--color-primary-hover, #16a34a);
}
/* Close button */
.bg-close-btn {
width: 100%;
margin-top: 0.25rem;
padding: 0.7rem;
border-radius: 12px;
border: 1.5px solid var(--color-border, #d4e8d4);
background: transparent;
color: var(--color-text, #1a1a1a);
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
}
.bg-close-btn:hover {
background: var(--color-accent-light, #dcfce7);
}
/* ============================
FULLSCREEN IMAGE CROPPER
============================ */
.cropper-overlay {
position: fixed;
inset: 0;
z-index: 500;
background: #000;
display: flex;
flex-direction: column;
touch-action: none;
}
.cropper-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1.25rem;
background: rgba(0, 0, 0, 0.85);
flex-shrink: 0;
gap: 1rem;
}
.cropper-title {
font-size: 0.95rem;
font-weight: 600;
color: #fff;
flex: 1;
text-align: center;
}
.cropper-cancel-btn {
background: none;
border: none;
color: #9ca3af;
font-size: 0.9rem;
cursor: pointer;
padding: 0.3rem 0.5rem;
min-width: 60px;
}
.cropper-apply-btn {
background: #22c55e;
border: none;
color: #fff;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
padding: 0.35rem 1rem;
border-radius: 8px;
min-width: 60px;
}
.cropper-apply-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.cropper-container {
flex: 1;
position: relative;
overflow: hidden;
touch-action: none;
user-select: none;
-webkit-user-select: none;
}
.cropper-image {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
pointer-events: none;
user-select: none;
-webkit-user-drag: none;
}
/* Dark area outside crop box via box-shadow */
.cropper-shade {
position: absolute;
pointer-events: none;
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.52);
box-sizing: border-box;
}
/* Crop box border */
.cropper-box {
position: absolute;
border: 2px solid rgba(255, 255, 255, 0.85);
box-sizing: border-box;
cursor: move;
touch-action: none;
}
/* Rule-of-thirds grid */
.cropper-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(to right, rgba(255,255,255,0.22) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255,255,255,0.22) 1px, transparent 1px);
background-size: 33.333% 33.333%;
pointer-events: none;
}
/* Resize handles */
.cropper-handle {
position: absolute;
width: 22px;
height: 22px;
background: #fff;
border-radius: 3px;
touch-action: none;
z-index: 1;
}
.cropper-handle-tl { top: -4px; left: -4px; cursor: nw-resize; }
.cropper-handle-tr { top: -4px; right: -4px; cursor: ne-resize; }
.cropper-handle-bl { bottom: -4px; left: -4px; cursor: sw-resize; }
.cropper-handle-br { bottom: -4px; right: -4px; cursor: se-resize; }
.cropper-hint {
text-align: center;
color: rgba(255, 255, 255, 0.45);
font-size: 0.72rem;
padding: 0.45rem;
flex-shrink: 0;
background: rgba(0, 0, 0, 0.7);
margin: 0;
}
/* ============================
DARK THEME — bg gallery
============================ */
[data-theme="dark"] .bg-modal {
background: #1a1a1a;
}
[data-theme="dark"] .bg-grid-add {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.12);
color: #9ca3af;
}
[data-theme="dark"] .bg-grid-add:hover:not(:disabled) {
border-color: #4ade80;
color: #4ade80;
}
[data-theme="dark"] .bg-grid-empty {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.1);
}
[data-theme="dark"] .bg-default-btn {
border-color: rgba(255, 255, 255, 0.12);
color: #9ca3af;
}
[data-theme="dark"] .bg-default-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.06);
border-color: #4ade80;
color: #4ade80;
}
[data-theme="dark"] .bg-close-btn {
border-color: rgba(255, 255, 255, 0.12);
color: #e8f5e8;
}
[data-theme="dark"] .bg-close-btn:hover {
background: rgba(255, 255, 255, 0.06);
}

View File

@@ -1,18 +1,22 @@
import { lazy, Suspense } from 'react'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { AuthProvider } from './contexts/AuthContext' import { AuthProvider } from './contexts/AuthContext'
import { ProtectedRoute } from './components/ProtectedRoute' import { ProtectedRoute } from './components/ProtectedRoute'
import HomePage from './pages/HomePage'
import HistoryPage from './pages/HistoryPage'
import SettingsPage from './pages/SettingsPage'
import LoginPage from './pages/LoginPage'
import PrivacyPage from './pages/PrivacyPage'
import AboutPage from './pages/AboutPage'
import './App.css' import './App.css'
const HomePage = lazy(() => import('./pages/HomePage'))
const HistoryPage = lazy(() => import('./pages/HistoryPage'))
const SettingsPage = lazy(() => import('./pages/SettingsPage'))
const LoginPage = lazy(() => import('./pages/LoginPage'))
const PrivacyPage = lazy(() => import('./pages/PrivacyPage'))
const AboutPage = lazy(() => import('./pages/AboutPage'))
const TermsOfServicePage = lazy(() => import('./pages/TermsOfServicePage'))
function App() { function App() {
return ( return (
<AuthProvider> <AuthProvider>
<BrowserRouter> <BrowserRouter>
<Suspense fallback={null}>
<Routes> <Routes>
<Route path="/" element={<LoginPage />} /> <Route path="/" element={<LoginPage />} />
<Route <Route
@@ -41,8 +45,10 @@ function App() {
/> />
<Route path="/privacy" element={<PrivacyPage />} /> <Route path="/privacy" element={<PrivacyPage />} />
<Route path="/about" element={<AboutPage />} /> <Route path="/about" element={<AboutPage />} />
<Route path="/termsofservice" element={<TermsOfServicePage />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</Suspense>
</BrowserRouter> </BrowserRouter>
</AuthProvider> </AuthProvider>
) )

View File

@@ -0,0 +1,223 @@
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
const dpr = Math.min(window.devicePixelRatio || 1, 2)
const outW = Math.min(Math.round(window.innerWidth * dpr), 1440)
const outH = Math.round(outW / aspectRatio)
const canvas = document.createElement('canvas')
canvas.width = outW
canvas.height = outH
const ctx = canvas.getContext('2d')!
ctx.drawImage(img, srcX, srcY, srcW, srcH, 0, 0, outW, outH)
onCrop(canvas.toDataURL('image/jpeg', 0.92))
}, [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>
)
}

View 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>
)
}

View File

@@ -38,6 +38,8 @@ type MongoUser = {
photoURL?: string photoURL?: string
theme?: string theme?: string
tutorial?: boolean tutorial?: boolean
backgroundImage?: string | null
backgroundImages?: string[]
} }
type AuthContextValue = { type AuthContextValue = {
@@ -62,6 +64,21 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [authError, setAuthError] = useState<string | null>(null) 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 // Initialize encryption keys on login
async function initializeEncryption(authUser: User) { async function initializeEncryption(authUser: User) {
try { try {

43
src/hooks/reminderApi.ts Normal file
View 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
View 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)
}

114
src/hooks/useReminder.ts Normal file
View File

@@ -0,0 +1,114 @@
/**
* 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) return null
// Use the already-registered sw.js — no second SW needed
const swReg = await navigator.serviceWorker.ready
return getToken(messaging, { vapidKey: VAPID_KEY, serviceWorkerRegistration: swReg })
}
/**
* Request permission, get FCM token, and save reminder settings to backend.
* Returns an error string on failure, or null on success.
*/
export async function enableReminder(
timeStr: string,
userId: string,
authToken: string
): Promise<string | null> {
if (!('Notification' in window)) {
return 'Notifications are not supported in this browser.'
}
let perm = Notification.permission
if (perm === 'default') {
perm = await Notification.requestPermission()
}
if (perm !== 'granted') {
return 'Permission denied. To enable reminders, allow notifications for this site in your browser settings.'
}
try {
const fcmToken = await getFcmToken()
if (!fcmToken) {
return 'Push notifications are not supported in this browser. Try Chrome or Edge.'
}
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
await saveFcmToken(userId, fcmToken, authToken)
await saveReminderSettings(userId, { time: timeStr, enabled: true, timezone }, authToken)
localStorage.setItem(REMINDER_TIME_KEY, timeStr)
localStorage.setItem(REMINDER_ENABLED_KEY, 'true')
return null
} catch (err) {
console.error('FCM reminder setup failed', err)
return 'Failed to set up push notification. Please try again.'
}
}
/** Pause the reminder (keeps the saved time). */
export async function disableReminder(userId: string, authToken: string): Promise<void> {
await saveReminderSettings(userId, { enabled: false }, authToken)
localStorage.setItem(REMINDER_ENABLED_KEY, 'false')
}
/** Re-enable using the previously saved time. Returns error string or null. */
export async function reenableReminder(userId: string, authToken: string): Promise<string | null> {
const time = localStorage.getItem(REMINDER_TIME_KEY)
if (!time) return 'No reminder time saved.'
return enableReminder(time, userId, authToken)
}
/**
* Listen for foreground FCM messages and show a manual notification.
* Call once after the app mounts. Returns an unsubscribe function.
*/
export async function listenForegroundMessages(): Promise<() => void> {
const messaging = await messagingPromise
if (!messaging) return () => {}
const unsubscribe = onMessage(messaging, (payload) => {
const title = payload.notification?.title || 'Grateful Journal 🌱'
const body = payload.notification?.body || "You haven't written today yet."
if (Notification.permission === 'granted') {
new Notification(title, {
body,
icon: '/web-app-manifest-192x192.png',
tag: 'gj-daily-reminder',
})
}
})
return unsubscribe
}

View File

@@ -24,7 +24,8 @@ input, textarea {
--color-primary: #22c55e; --color-primary: #22c55e;
--color-primary-hover: #16a34a; --color-primary-hover: #16a34a;
--color-bg-soft: #eef6ee; --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-accent-light: #dcfce7;
--color-text: #1a1a1a; --color-text: #1a1a1a;
--color-text-muted: #6b7280; --color-text-muted: #6b7280;
@@ -81,7 +82,7 @@ button:focus-visible {
--color-primary: #4ade80; --color-primary: #4ade80;
--color-primary-hover: #22c55e; --color-primary-hover: #22c55e;
--color-bg-soft: #0f0f0f; --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-accent-light: rgba(74, 222, 128, 0.12);
--color-text: #e8f5e8; --color-text: #e8f5e8;
--color-text-muted: #7a8a7a; --color-text-muted: #7a8a7a;

View File

@@ -70,7 +70,7 @@ export async function getUserByEmail(email: string, token: string) {
export async function updateUserProfile( export async function updateUserProfile(
userId: string, 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 token: string
) { ) {
return apiCall(`/users/${userId}`, { return apiCall(`/users/${userId}`, {

View File

@@ -1,5 +1,6 @@
import { initializeApp } from 'firebase/app' import { initializeApp } from 'firebase/app'
import { getAuth, GoogleAuthProvider } from 'firebase/auth' import { getAuth, GoogleAuthProvider } from 'firebase/auth'
import { getMessaging, isSupported } from 'firebase/messaging'
const firebaseConfig = { const firebaseConfig = {
apiKey: import.meta.env.VITE_FIREBASE_API_KEY, apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
@@ -15,3 +16,6 @@ const app = initializeApp(firebaseConfig)
// Google Auth initialization // Google Auth initialization
export const auth = getAuth(app) export const auth = getAuth(app)
export const googleProvider = new GoogleAuthProvider() 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))

View File

@@ -2,6 +2,7 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './index.css' import './index.css'
import App from './App.tsx' import App from './App.tsx'
import { listenForegroundMessages } from './hooks/useReminder'
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
window.addEventListener('load', () => { window.addEventListener('load', () => {
@@ -9,6 +10,9 @@ if ('serviceWorker' in navigator) {
}) })
} }
// Show FCM notifications when app is open in foreground
listenForegroundMessages()
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<App /> <App />

View File

@@ -1,6 +1,14 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { usePageMeta } from '../hooks/usePageMeta'
export default function AboutPage() { export default function AboutPage() {
usePageMeta({
title: 'About — Grateful Journal',
description: 'Learn about Grateful Journal — a free, end-to-end encrypted daily gratitude journal. No ads, no tracking, no social feed. Just you and your thoughts.',
canonical: 'https://gratefuljournal.online/about',
ogTitle: 'About Grateful Journal',
ogDescription: 'A free, private gratitude journal with end-to-end encryption. Learn how we built a distraction-free space for your daily reflection practice.',
})
return ( return (
<div className="static-page"> <div className="static-page">
<header className="static-page__header"> <header className="static-page__header">

View File

@@ -4,8 +4,14 @@ import { useEffect, useState } from 'react'
import { GoogleSignInButton } from '../components/GoogleSignInButton' import { GoogleSignInButton } from '../components/GoogleSignInButton'
import { TreeAnimation } from '../components/TreeAnimation' import { TreeAnimation } from '../components/TreeAnimation'
import { PageLoader } from '../components/PageLoader' import { PageLoader } from '../components/PageLoader'
import { usePageMeta } from '../hooks/usePageMeta'
export default function LoginPage() { export default function LoginPage() {
usePageMeta({
title: 'Grateful Journal — Your Private Gratitude Journal',
description: 'A private, end-to-end encrypted gratitude journal. No feeds, no noise — just you and your thoughts. Grow your gratitude one moment at a time.',
canonical: 'https://gratefuljournal.online/',
})
const { user, loading, signInWithGoogle, authError } = useAuth() const { user, loading, signInWithGoogle, authError } = useAuth()
const navigate = useNavigate() const navigate = useNavigate()
const [signingIn, setSigningIn] = useState(false) const [signingIn, setSigningIn] = useState(false)

View File

@@ -1,6 +1,14 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { usePageMeta } from '../hooks/usePageMeta'
export default function PrivacyPage() { export default function PrivacyPage() {
usePageMeta({
title: 'Privacy Policy — Grateful Journal',
description: 'Grateful Journal\'s privacy policy. Your journal entries are end-to-end encrypted — we cannot read them. No ads, no tracking, no data selling.',
canonical: 'https://gratefuljournal.online/privacy',
ogTitle: 'Privacy Policy — Grateful Journal',
ogDescription: 'Your journal entries are end-to-end encrypted and private. We cannot read them, we don\'t sell your data, and we use no advertising cookies.',
})
return ( return (
<div className="static-page"> <div className="static-page">
<header className="static-page__header"> <header className="static-page__header">

View File

@@ -1,14 +1,21 @@
import { useState, useEffect, useCallback, useRef } from 'react' import { useState, useEffect, useCallback, useRef } from 'react'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { deleteUser as deleteUserApi, updateUserProfile } from '../lib/api' import { deleteUser as deleteUserApi, updateUserProfile } from '../lib/api'
import { BgImageCropper } from '../components/BgImageCropper'
import { clearDeviceKey, clearEncryptedSecretKey } from '../lib/crypto' import { clearDeviceKey, clearEncryptedSecretKey } from '../lib/crypto'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import BottomNav from '../components/BottomNav' import BottomNav from '../components/BottomNav'
import { useOnboardingTour, hasPendingTourStep, clearPendingTourStep } from '../hooks/useOnboardingTour' import { useOnboardingTour, hasPendingTourStep, clearPendingTourStep } from '../hooks/useOnboardingTour'
import { PageLoader } from '../components/PageLoader' import { PageLoader } from '../components/PageLoader'
import { usePWAInstall } from '../hooks/usePWAInstall' import { usePWAInstall } from '../hooks/usePWAInstall'
import {
getSavedReminderTime, isReminderEnabled,
enableReminder, disableReminder, reenableReminder,
} from '../hooks/useReminder'
import ClockTimePicker from '../components/ClockTimePicker'
const MAX_PHOTO_SIZE = 200 // px — resize to 200x200 const MAX_PHOTO_SIZE = 200 // px — resize to 200x200
const MAX_BG_HISTORY = 3
function resizeImage(file: File): Promise<string> { function resizeImage(file: File): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -55,12 +62,32 @@ export default function SettingsPage() {
const { canInstall, isIOS, triggerInstall } = usePWAInstall() const { canInstall, isIOS, triggerInstall } = usePWAInstall()
const [installModal, setInstallModal] = useState<'ios' | 'chrome' | null>(null) 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 // Edit profile modal state
const [showEditModal, setShowEditModal] = useState(false) const [showEditModal, setShowEditModal] = useState(false)
const [editName, setEditName] = useState('') const [editName, setEditName] = useState('')
const [editPhotoPreview, setEditPhotoPreview] = useState<string | null>(null) const [editPhotoPreview, setEditPhotoPreview] = useState<string | null>(null)
const [saving, setSaving] = useState(false) 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 // Continue onboarding tour if navigated here from the history page tour
useEffect(() => { useEffect(() => {
if (hasPendingTourStep() === 'settings') { if (hasPendingTourStep() === 'settings') {
@@ -124,6 +151,61 @@ 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)
// Prepend to history, deduplicate, cap at MAX_BG_HISTORY
const newHistory = [dataUrl, ...bgImages.filter(i => i !== dataUrl)].slice(0, MAX_BG_HISTORY)
await bgUpdate({ backgroundImage: dataUrl, backgroundImages: newHistory })
}
const handleCropCancel = () => {
if (cropperSrc) URL.revokeObjectURL(cropperSrc)
setCropperSrc(null)
}
// Apply theme to DOM // Apply theme to DOM
const applyTheme = useCallback((t: 'light' | 'dark') => { const applyTheme = useCallback((t: 'light' | 'dark') => {
document.documentElement.setAttribute('data-theme', t) document.documentElement.setAttribute('data-theme', t)
@@ -182,6 +264,56 @@ export default function SettingsPage() {
} }
} }
const handleOpenReminderModal = () => {
setReminderPickedTime(reminderTime || '08:00')
setReminderError(null)
setShowReminderModal(true)
}
const handleSaveReminder = async () => {
if (!user || !userId) return
setReminderSaving(true)
setReminderError(null)
const authToken = await user.getIdToken()
const error = await enableReminder(reminderPickedTime, userId, authToken)
setReminderSaving(false)
if (error) {
setReminderError(error)
} else {
setReminderTime(reminderPickedTime)
setReminderEnabled(true)
setShowReminderModal(false)
setMessage({ type: 'success', text: 'Reminder set!' })
setTimeout(() => setMessage(null), 2000)
}
}
const handleReminderToggle = async () => {
if (!user || !userId) return
if (!reminderTime) {
handleOpenReminderModal()
return
}
if (reminderEnabled) {
const authToken = await user.getIdToken()
await disableReminder(userId, authToken)
setReminderEnabled(false)
} else {
setReminderSaving(true)
const authToken = await user.getIdToken()
const error = await reenableReminder(userId, authToken)
setReminderSaving(false)
if (error) {
setReminderError(error)
setShowReminderModal(true)
} else {
setReminderEnabled(true)
setMessage({ type: 'success', text: 'Reminder enabled!' })
setTimeout(() => setMessage(null), 2000)
}
}
}
const handleSignOut = async () => { const handleSignOut = async () => {
try { try {
await signOut() await signOut()
@@ -318,6 +450,40 @@ export default function SettingsPage() {
<polyline points="9 18 15 12 9 6" /> <polyline points="9 18 15 12 9 6" />
</svg> </svg>
</button> </button>
<div className="settings-divider"></div>
{/* Daily Reminder */}
<div className="settings-item">
<div className="settings-item-icon settings-item-icon-orange">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</svg>
</div>
<button
type="button"
className="settings-item-content"
onClick={handleOpenReminderModal}
style={{ background: 'none', border: 'none', cursor: 'pointer', textAlign: 'left', padding: 0 }}
>
<h4 className="settings-item-title">Daily Reminder</h4>
<p className="settings-item-subtitle">
{reminderTime
? (reminderEnabled ? `Reminds you at ${reminderTime}` : `Set to ${reminderTime} — paused`)
: 'Tap to set a daily reminder'}
</p>
</button>
<label className="settings-toggle" title={reminderEnabled ? 'Disable reminder' : 'Enable reminder'}>
<input
type="checkbox"
checked={reminderEnabled}
onChange={handleReminderToggle}
disabled={reminderSaving}
/>
<span className="settings-toggle-slider"></span>
</label>
</div>
</div> </div>
</section> </section>
@@ -347,6 +513,27 @@ export default function SettingsPage() {
<div className="settings-divider"></div> <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 id="tour-theme-switcher" className="settings-item">
<div className="settings-item-icon settings-item-icon-blue"> <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"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
@@ -622,6 +809,177 @@ export default function SettingsPage() {
</div> </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>
</div>
</div>
)}
<BottomNav /> <BottomNav />
</div> </div>
) )

View File

@@ -0,0 +1,106 @@
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. Read about the rules and guidelines for using our app.',
canonical: 'https://gratefuljournal.online/termsofservice',
ogTitle: 'Terms of Service — Grateful Journal',
ogDescription: 'Terms of Service for Grateful Journal. Read about the rules and guidelines for using our app.',
})
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 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:</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>
)
}

View File

@@ -1,17 +1,47 @@
import { defineConfig } from 'vite' import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import fs from 'fs' import fs from 'fs'
import path from 'path' import path from 'path'
function swBuildTimePlugin() { function injectFirebaseConfig(content: string, env: Record<string, string>): string {
return content
.replace('__VITE_FIREBASE_API_KEY__', env.VITE_FIREBASE_API_KEY || '')
.replace('__VITE_FIREBASE_AUTH_DOMAIN__', env.VITE_FIREBASE_AUTH_DOMAIN || '')
.replace('__VITE_FIREBASE_PROJECT_ID__', env.VITE_FIREBASE_PROJECT_ID || '')
.replace('__VITE_FIREBASE_MESSAGING_SENDER_ID__', env.VITE_FIREBASE_MESSAGING_SENDER_ID || '')
.replace('__VITE_FIREBASE_APP_ID__', env.VITE_FIREBASE_APP_ID || '')
}
function swPlugin() {
let env: Record<string, string> = {}
return { 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() { closeBundle() {
// Cache-bust sw.js and inject Firebase config
const swPath = path.resolve(__dirname, 'dist/sw.js') const swPath = path.resolve(__dirname, 'dist/sw.js')
if (fs.existsSync(swPath)) { if (fs.existsSync(swPath)) {
const content = fs.readFileSync(swPath, 'utf-8') let content = fs.readFileSync(swPath, 'utf-8')
const timestamp = Date.now().toString() content = content.replace('__BUILD_TIME__', Date.now().toString())
fs.writeFileSync(swPath, content.replace('__BUILD_TIME__', timestamp)) content = injectFirebaseConfig(content, env)
fs.writeFileSync(swPath, content)
} }
}, },
} }
@@ -19,7 +49,7 @@ function swBuildTimePlugin() {
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react(), swBuildTimePlugin()], plugins: [react(), swPlugin()],
server: { server: {
port: 8000, port: 8000,
strictPort: false, strictPort: false,
@@ -27,5 +57,30 @@ export default defineConfig({
optimizeDeps: { optimizeDeps: {
include: ['libsodium-wrappers'], include: ['libsodium-wrappers'],
}, },
build: {
chunkSizeWarningLimit: 1000,
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules/firebase')) {
return 'firebase'
}
if (
id.includes('node_modules/react/') ||
id.includes('node_modules/react-dom/') ||
id.includes('node_modules/react-router-dom/')
) {
return 'react-vendor'
}
if (id.includes('node_modules/libsodium')) {
return 'crypto'
}
if (id.includes('node_modules/driver.js')) {
return 'driver'
}
},
},
},
},
}) })