Compare commits
6 Commits
df4bb88f70
...
19dcd73b29
| Author | SHA1 | Date | |
|---|---|---|---|
| 19dcd73b29 | |||
| a1ac8e7933 | |||
| 4d3a0ca1bd | |||
| 937a98c58d | |||
| 1353dfc69d | |||
| 34254f94f9 |
@@ -15,7 +15,15 @@
|
|||||||
"Bash(find /Users/jeet/Desktop/Jio/grateful-journal/src -type f -name *.ts -o -name *.tsx)",
|
"Bash(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
15
TODO.md
@@ -1,15 +0,0 @@
|
|||||||
# TODO
|
|
||||||
|
|
||||||
## 1. Tree Growing Animation on Journal Save
|
|
||||||
When a new journal entry is saved, show a tree growing animation — a visual metaphor for gratitude growing over time.
|
|
||||||
- Trigger animation after successful save
|
|
||||||
- Tree sprouts from seed → sapling → full tree
|
|
||||||
- Could use CSS/SVG animation or a canvas-based approach
|
|
||||||
- Consider making it dismissible / auto-fade after completion
|
|
||||||
|
|
||||||
## 2. Smoother Google Auth Flow
|
|
||||||
Improve the UX of the Google OAuth flow.
|
|
||||||
- Reduce redirect friction (loading states, transitions)
|
|
||||||
- Show a proper loading screen during the OAuth callback
|
|
||||||
- Handle errors gracefully with user-friendly messages
|
|
||||||
- Consider persisting intent so users land back where they started
|
|
||||||
@@ -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=
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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",
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
78
backend/routers/notifications.py
Normal file
78
backend/routers/notifications.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"""Notification routes — FCM token registration and reminder settings."""
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from db import get_database
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
from bson import ObjectId
|
||||||
|
from bson.errors import InvalidId
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class FcmTokenRequest(BaseModel):
|
||||||
|
userId: str
|
||||||
|
fcmToken: str
|
||||||
|
|
||||||
|
|
||||||
|
class ReminderSettingsRequest(BaseModel):
|
||||||
|
time: Optional[str] = None # "HH:MM" in 24-hour format
|
||||||
|
enabled: bool
|
||||||
|
timezone: Optional[str] = None # IANA timezone, e.g. "Asia/Kolkata"
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/fcm-token", response_model=dict)
|
||||||
|
async def register_fcm_token(body: FcmTokenRequest):
|
||||||
|
"""
|
||||||
|
Register (or refresh) an FCM device token for a user.
|
||||||
|
Stores unique tokens per user — duplicate tokens are ignored.
|
||||||
|
"""
|
||||||
|
db = get_database()
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_oid = ObjectId(body.userId)
|
||||||
|
except InvalidId:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid user ID")
|
||||||
|
|
||||||
|
user = db.users.find_one({"_id": user_oid})
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
# Add token to set (avoid duplicates)
|
||||||
|
db.users.update_one(
|
||||||
|
{"_id": user_oid},
|
||||||
|
{
|
||||||
|
"$addToSet": {"fcmTokens": body.fcmToken},
|
||||||
|
"$set": {"updatedAt": datetime.utcnow()},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"message": "FCM token registered"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/reminder/{user_id}", response_model=dict)
|
||||||
|
async def update_reminder(user_id: str, settings: ReminderSettingsRequest):
|
||||||
|
"""
|
||||||
|
Save or update daily reminder settings for a user.
|
||||||
|
"""
|
||||||
|
db = get_database()
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_oid = ObjectId(user_id)
|
||||||
|
except InvalidId:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid user ID")
|
||||||
|
|
||||||
|
user = db.users.find_one({"_id": user_oid})
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
reminder_update: dict = {"reminder.enabled": settings.enabled}
|
||||||
|
if settings.time is not None:
|
||||||
|
reminder_update["reminder.time"] = settings.time
|
||||||
|
if settings.timezone is not None:
|
||||||
|
reminder_update["reminder.timezone"] = settings.timezone
|
||||||
|
|
||||||
|
db.users.update_one(
|
||||||
|
{"_id": user_oid},
|
||||||
|
{"$set": {**reminder_update, "updatedAt": datetime.utcnow()}}
|
||||||
|
)
|
||||||
|
return {"message": "Reminder settings updated"}
|
||||||
@@ -52,6 +52,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
179
backend/scheduler.py
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
"""
|
||||||
|
Daily reminder scheduler.
|
||||||
|
|
||||||
|
Runs every minute. For each user with an enabled reminder:
|
||||||
|
- Converts current UTC time to the user's local timezone
|
||||||
|
- Checks if the current HH:MM matches their reminder time
|
||||||
|
- Checks if they already got a notification today (avoids duplicates)
|
||||||
|
- Checks if they have already written a journal entry today
|
||||||
|
- If not, sends an FCM push notification to all their registered devices
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
import firebase_admin
|
||||||
|
from firebase_admin import credentials, messaging
|
||||||
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
|
|
||||||
|
from config import get_settings
|
||||||
|
from db import get_database
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_firebase_initialized = False
|
||||||
|
|
||||||
|
|
||||||
|
def init_firebase():
|
||||||
|
"""Initialize Firebase Admin SDK once using the service account JSON from env."""
|
||||||
|
global _firebase_initialized
|
||||||
|
if _firebase_initialized:
|
||||||
|
return
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.firebase_service_account_json:
|
||||||
|
log.warning("FIREBASE_SERVICE_ACCOUNT_JSON not set — push notifications disabled")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
sa_dict = json.loads(settings.firebase_service_account_json)
|
||||||
|
cred = credentials.Certificate(sa_dict)
|
||||||
|
firebase_admin.initialize_app(cred)
|
||||||
|
_firebase_initialized = True
|
||||||
|
log.info("Firebase Admin SDK initialized")
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Failed to initialize Firebase Admin SDK: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def send_reminder_notifications():
|
||||||
|
"""Check all users and send reminders where due."""
|
||||||
|
if not _firebase_initialized:
|
||||||
|
return
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
now_utc = datetime.utcnow().replace(second=0, microsecond=0)
|
||||||
|
|
||||||
|
# Find all users with reminder enabled and at least one FCM token
|
||||||
|
users = db.users.find({
|
||||||
|
"reminder.enabled": True,
|
||||||
|
"fcmTokens": {"$exists": True, "$not": {"$size": 0}},
|
||||||
|
"reminder.time": {"$exists": True},
|
||||||
|
})
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
try:
|
||||||
|
_process_user(db, user, now_utc)
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Error processing reminder for user {user.get('_id')}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _process_user(db, user: dict, now_utc: datetime):
|
||||||
|
reminder = user.get("reminder", {})
|
||||||
|
reminder_time_str = reminder.get("time") # "HH:MM"
|
||||||
|
timezone_str = reminder.get("timezone", "UTC")
|
||||||
|
fcm_tokens: list = user.get("fcmTokens", [])
|
||||||
|
|
||||||
|
if not reminder_time_str or not fcm_tokens:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_tz = pytz.timezone(timezone_str)
|
||||||
|
except pytz.UnknownTimeZoneError:
|
||||||
|
user_tz = pytz.utc
|
||||||
|
|
||||||
|
# Current time in user's timezone
|
||||||
|
now_local = now_utc.replace(tzinfo=pytz.utc).astimezone(user_tz)
|
||||||
|
current_hm = now_local.strftime("%H:%M")
|
||||||
|
|
||||||
|
if current_hm != reminder_time_str:
|
||||||
|
return # Not the right minute
|
||||||
|
|
||||||
|
# Check if already notified today (in user's local date)
|
||||||
|
today_local_str = now_local.strftime("%Y-%m-%d")
|
||||||
|
last_notified = reminder.get("lastNotifiedDate", "")
|
||||||
|
if last_notified == today_local_str:
|
||||||
|
return # Already sent today
|
||||||
|
|
||||||
|
# Check if user has already written today (using createdAt in their timezone)
|
||||||
|
today_start_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
today_start_utc = today_start_local.astimezone(pytz.utc).replace(tzinfo=None)
|
||||||
|
today_end_utc = today_start_utc + timedelta(days=1)
|
||||||
|
|
||||||
|
entry_count = db.entries.count_documents({
|
||||||
|
"userId": user["_id"],
|
||||||
|
"createdAt": {"$gte": today_start_utc, "$lt": today_end_utc},
|
||||||
|
})
|
||||||
|
|
||||||
|
if entry_count > 0:
|
||||||
|
# Already wrote today — mark notified to avoid repeated checks
|
||||||
|
db.users.update_one(
|
||||||
|
{"_id": user["_id"]},
|
||||||
|
{"$set": {"reminder.lastNotifiedDate": today_local_str}}
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Send FCM notification
|
||||||
|
_send_push(user["_id"], fcm_tokens, db, today_local_str)
|
||||||
|
|
||||||
|
|
||||||
|
def _send_push(user_id, tokens: list, db, today_local_str: str):
|
||||||
|
"""Send FCM multicast and prune stale tokens."""
|
||||||
|
message = messaging.MulticastMessage(
|
||||||
|
notification=messaging.Notification(
|
||||||
|
title="Time to journal 🌱",
|
||||||
|
body="You haven't written today yet. Take a moment to reflect.",
|
||||||
|
),
|
||||||
|
tokens=tokens,
|
||||||
|
android=messaging.AndroidConfig(priority="high"),
|
||||||
|
apns=messaging.APNSConfig(
|
||||||
|
payload=messaging.APNSPayload(
|
||||||
|
aps=messaging.Aps(sound="default")
|
||||||
|
)
|
||||||
|
),
|
||||||
|
webpush=messaging.WebpushConfig(
|
||||||
|
notification=messaging.WebpushNotification(
|
||||||
|
icon="/web-app-manifest-192x192.png",
|
||||||
|
badge="/favicon-96x96.png",
|
||||||
|
tag="gj-daily-reminder",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = messaging.send_each_for_multicast(message)
|
||||||
|
log.info(f"Reminder sent to user {user_id}: {response.success_count} ok, {response.failure_count} failed")
|
||||||
|
|
||||||
|
# Remove tokens that are no longer valid
|
||||||
|
stale_tokens = [
|
||||||
|
tokens[i] for i, r in enumerate(response.responses)
|
||||||
|
if not r.success and r.exception and "not-registered" in str(r.exception).lower()
|
||||||
|
]
|
||||||
|
if stale_tokens:
|
||||||
|
db.users.update_one(
|
||||||
|
{"_id": user_id},
|
||||||
|
{"$pullAll": {"fcmTokens": stale_tokens}}
|
||||||
|
)
|
||||||
|
log.info(f"Removed {len(stale_tokens)} stale FCM tokens for user {user_id}")
|
||||||
|
|
||||||
|
# Mark today as notified
|
||||||
|
db.users.update_one(
|
||||||
|
{"_id": user_id},
|
||||||
|
{"$set": {"reminder.lastNotifiedDate": today_local_str}}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def start_scheduler() -> BackgroundScheduler:
|
||||||
|
"""Initialize Firebase and start the minute-by-minute scheduler."""
|
||||||
|
init_firebase()
|
||||||
|
|
||||||
|
scheduler = BackgroundScheduler(timezone="UTC")
|
||||||
|
scheduler.add_job(
|
||||||
|
send_reminder_notifications,
|
||||||
|
trigger="cron",
|
||||||
|
minute="*", # every minute
|
||||||
|
id="daily_reminders",
|
||||||
|
replace_existing=True,
|
||||||
|
)
|
||||||
|
scheduler.start()
|
||||||
|
log.info("Reminder scheduler started")
|
||||||
|
return scheduler
|
||||||
135
index.html
135
index.html
@@ -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
122
liquidglass.md
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
# Liquid Glass Theme Implementation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Replaces solid white/dark card surfaces with a unified glassmorphism effect using CSS `backdrop-filter`. No library needed — pure CSS. Works identically on both light and dark themes with only variable overrides per theme.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. `src/index.css` changes
|
||||||
|
|
||||||
|
### `:root` — replace `--card-bg-opacity` + `--color-surface` with:
|
||||||
|
```css
|
||||||
|
--glass-bg: rgba(255, 255, 255, 0.55);
|
||||||
|
--glass-blur: blur(18px) saturate(160%);
|
||||||
|
--glass-border: 1px solid rgba(255, 255, 255, 0.5);
|
||||||
|
--glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.08);
|
||||||
|
--color-surface: var(--glass-bg);
|
||||||
|
```
|
||||||
|
|
||||||
|
### `[data-theme="dark"]` — replace `--color-surface: rgb(26 26 26 / ...)` with:
|
||||||
|
```css
|
||||||
|
--glass-bg: rgba(255, 255, 255, 0.07);
|
||||||
|
--glass-border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
--glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||||
|
--color-surface: var(--glass-bg);
|
||||||
|
```
|
||||||
|
> `--glass-blur` is NOT redeclared in dark — it inherits the same blur from `:root`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. `src/App.css` additions
|
||||||
|
|
||||||
|
### Add this block BEFORE the `SHARED PAGE SHELL` section (~line 403):
|
||||||
|
```css
|
||||||
|
/* ============================
|
||||||
|
LIQUID GLASS – applied to all card/surface elements
|
||||||
|
============================ */
|
||||||
|
.journal-card,
|
||||||
|
.calendar-card,
|
||||||
|
.entry-card,
|
||||||
|
.entry-modal,
|
||||||
|
.confirm-modal,
|
||||||
|
.settings-profile,
|
||||||
|
.settings-card,
|
||||||
|
.settings-tutorial-btn,
|
||||||
|
.settings-clear-btn,
|
||||||
|
.settings-signout-btn,
|
||||||
|
.bottom-nav,
|
||||||
|
.lp__form {
|
||||||
|
backdrop-filter: var(--glass-blur);
|
||||||
|
-webkit-backdrop-filter: var(--glass-blur);
|
||||||
|
border: var(--glass-border);
|
||||||
|
box-shadow: var(--glass-shadow);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Remove individual `box-shadow` from these classes (glass rule handles it):
|
||||||
|
- `.journal-card` — remove `box-shadow: 0 2px 12px rgba(0,0,0,0.07)`
|
||||||
|
- `.calendar-card` — remove `box-shadow: 0 2px 10px rgba(0,0,0,0.06)`
|
||||||
|
- `.entry-card` — remove `box-shadow: 0 2px 6px rgba(0,0,0,0.05)`
|
||||||
|
- `.settings-profile` — remove `box-shadow: 0 2px 10px rgba(0,0,0,0.06)`
|
||||||
|
- `.settings-card` — remove `box-shadow: 0 2px 10px rgba(0,0,0,0.06)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. `src/App.css` dark mode cleanup
|
||||||
|
|
||||||
|
### Remove entire block (now redundant — glass vars handle background + shadow):
|
||||||
|
```css
|
||||||
|
/* -- Cards & surfaces -- */
|
||||||
|
[data-theme="dark"] .journal-card,
|
||||||
|
[data-theme="dark"] .calendar-card,
|
||||||
|
[data-theme="dark"] .settings-card,
|
||||||
|
[data-theme="dark"] .settings-profile,
|
||||||
|
[data-theme="dark"] .entry-card {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-color: rgba(74, 222, 128, 0.12);
|
||||||
|
box-shadow:
|
||||||
|
0 2px 16px rgba(0, 0, 0, 0.4),
|
||||||
|
0 0 0 1px rgba(74, 222, 128, 0.06);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Collapse settings buttons dark overrides to color-only:
|
||||||
|
```css
|
||||||
|
/* -- Settings buttons -- */
|
||||||
|
[data-theme="dark"] .settings-clear-btn { color: #f87171; }
|
||||||
|
[data-theme="dark"] .settings-signout-btn { color: #9ca3af; }
|
||||||
|
[data-theme="dark"] .settings-signout-btn:hover { color: #d1d5db; }
|
||||||
|
```
|
||||||
|
> Remove the full blocks that were setting `background: var(--color-surface)` and `box-shadow` for `.settings-tutorial-btn`, `.settings-clear-btn`, `.settings-signout-btn`.
|
||||||
|
|
||||||
|
### Entry modal dark override — keep only the border accent:
|
||||||
|
```css
|
||||||
|
[data-theme="dark"] .entry-modal {
|
||||||
|
border-top-color: #4ade80;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
> Remove the `background` and `box-shadow` lines.
|
||||||
|
|
||||||
|
### Remove entirely:
|
||||||
|
```css
|
||||||
|
[data-theme="dark"] .delete-confirm-modal { background: var(--color-surface); }
|
||||||
|
[data-theme="dark"] .confirm-modal { background: var(--color-surface); box-shadow: ...; }
|
||||||
|
```
|
||||||
|
|
||||||
|
### History search button — keep only color:
|
||||||
|
```css
|
||||||
|
[data-theme="dark"] .history-search-btn { color: #7a8a7a; }
|
||||||
|
```
|
||||||
|
> Remove `background` and `border-color` lines.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tuning
|
||||||
|
|
||||||
|
| Variable | What it controls |
|
||||||
|
|---|---|
|
||||||
|
| `--glass-bg` opacity | How transparent the cards are (0.55 = light, 0.07 = dark) |
|
||||||
|
| `--glass-blur` value | How much the background blurs through |
|
||||||
|
| `--glass-border` opacity | Strength of the frosted edge highlight |
|
||||||
|
|
||||||
|
To make glass more/less opaque: change the alpha in `--glass-bg` in `:root` / `[data-theme="dark"]`.
|
||||||
@@ -1,3 +1,24 @@
|
|||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
gzip_min_length 256;
|
||||||
|
gzip_types
|
||||||
|
text/plain
|
||||||
|
text/css
|
||||||
|
text/xml
|
||||||
|
text/javascript
|
||||||
|
application/javascript
|
||||||
|
application/x-javascript
|
||||||
|
application/json
|
||||||
|
application/xml
|
||||||
|
application/rss+xml
|
||||||
|
application/atom+xml
|
||||||
|
image/svg+xml
|
||||||
|
font/truetype
|
||||||
|
font/opentype
|
||||||
|
application/vnd.ms-fontobject;
|
||||||
|
|
||||||
server {
|
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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
47
public/sw.js
47
public/sw.js
@@ -1,3 +1,29 @@
|
|||||||
|
// Firebase Messaging — handles background push notifications
|
||||||
|
importScripts('https://www.gstatic.com/firebasejs/10.12.0/firebase-app-compat.js')
|
||||||
|
importScripts('https://www.gstatic.com/firebasejs/10.12.0/firebase-messaging-compat.js')
|
||||||
|
|
||||||
|
firebase.initializeApp({
|
||||||
|
apiKey: '__VITE_FIREBASE_API_KEY__',
|
||||||
|
authDomain: '__VITE_FIREBASE_AUTH_DOMAIN__',
|
||||||
|
projectId: '__VITE_FIREBASE_PROJECT_ID__',
|
||||||
|
messagingSenderId: '__VITE_FIREBASE_MESSAGING_SENDER_ID__',
|
||||||
|
appId: '__VITE_FIREBASE_APP_ID__',
|
||||||
|
})
|
||||||
|
|
||||||
|
const messaging = firebase.messaging()
|
||||||
|
|
||||||
|
messaging.onBackgroundMessage((payload) => {
|
||||||
|
const title = payload.notification?.title || 'Grateful Journal 🌱'
|
||||||
|
const body = payload.notification?.body || "You haven't written today yet. Take a moment to reflect."
|
||||||
|
self.registration.showNotification(title, {
|
||||||
|
body,
|
||||||
|
icon: '/web-app-manifest-192x192.png',
|
||||||
|
badge: '/favicon-96x96.png',
|
||||||
|
tag: 'gj-daily-reminder',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Cache management
|
||||||
const CACHE = 'gj-__BUILD_TIME__'
|
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))
|
||||||
)
|
)
|
||||||
|
|||||||
645
src/App.css
645
src/App.css
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
18
src/App.tsx
18
src/App.tsx
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
223
src/components/BgImageCropper.tsx
Normal file
223
src/components/BgImageCropper.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
274
src/components/ClockTimePicker.tsx
Normal file
274
src/components/ClockTimePicker.tsx
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: string // "HH:MM" 24-hour format
|
||||||
|
onChange: (value: string) => void
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIZE = 240
|
||||||
|
const CENTER = SIZE / 2
|
||||||
|
const CLOCK_RADIUS = 108
|
||||||
|
const NUM_RADIUS = 82
|
||||||
|
const HAND_RADIUS = 74
|
||||||
|
const TIP_RADIUS = 16
|
||||||
|
|
||||||
|
function polarToXY(angleDeg: number, radius: number) {
|
||||||
|
const rad = ((angleDeg - 90) * Math.PI) / 180
|
||||||
|
return {
|
||||||
|
x: CENTER + radius * Math.cos(rad),
|
||||||
|
y: CENTER + radius * Math.sin(rad),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseValue(v: string): { h: number; m: number } {
|
||||||
|
const [h, m] = v.split(':').map(Number)
|
||||||
|
return { h: isNaN(h) ? 8 : h, m: isNaN(m) ? 0 : m }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ClockTimePicker({ value, onChange, disabled }: Props) {
|
||||||
|
const { h: initH, m: initM } = parseValue(value)
|
||||||
|
|
||||||
|
const [mode, setMode] = useState<'hours' | 'minutes'>('hours')
|
||||||
|
const [hour24, setHour24] = useState(initH)
|
||||||
|
const [minute, setMinute] = useState(initM)
|
||||||
|
const svgRef = useRef<SVGSVGElement>(null)
|
||||||
|
const isDragging = useRef(false)
|
||||||
|
// Keep mutable refs for use inside native event listeners
|
||||||
|
const modeRef = useRef(mode)
|
||||||
|
const isPMRef = useRef(initH >= 12)
|
||||||
|
const hour24Ref = useRef(initH)
|
||||||
|
const minuteRef = useRef(initM)
|
||||||
|
|
||||||
|
// Keep refs in sync with state
|
||||||
|
useEffect(() => { modeRef.current = mode }, [mode])
|
||||||
|
useEffect(() => { isPMRef.current = hour24 >= 12 }, [hour24])
|
||||||
|
useEffect(() => { hour24Ref.current = hour24 }, [hour24])
|
||||||
|
useEffect(() => { minuteRef.current = minute }, [minute])
|
||||||
|
|
||||||
|
// Sync when value prop changes externally
|
||||||
|
useEffect(() => {
|
||||||
|
const { h, m } = parseValue(value)
|
||||||
|
setHour24(h)
|
||||||
|
setMinute(m)
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
const isPM = hour24 >= 12
|
||||||
|
const hour12 = hour24 === 0 ? 12 : hour24 > 12 ? hour24 - 12 : hour24
|
||||||
|
|
||||||
|
const emit = useCallback(
|
||||||
|
(h24: number, m: number) => {
|
||||||
|
onChange(`${h24.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`)
|
||||||
|
},
|
||||||
|
[onChange]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleAmPm = (pm: boolean) => {
|
||||||
|
if (disabled) return
|
||||||
|
let newH = hour24
|
||||||
|
if (pm && hour24 < 12) newH = hour24 + 12
|
||||||
|
else if (!pm && hour24 >= 12) newH = hour24 - 12
|
||||||
|
setHour24(newH)
|
||||||
|
emit(newH, minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyAngle = useCallback(
|
||||||
|
(angle: number, currentMode: 'hours' | 'minutes') => {
|
||||||
|
if (currentMode === 'hours') {
|
||||||
|
const h12 = Math.round(angle / 30) % 12 || 12
|
||||||
|
const pm = isPMRef.current
|
||||||
|
const newH24 = pm ? (h12 === 12 ? 12 : h12 + 12) : (h12 === 12 ? 0 : h12)
|
||||||
|
setHour24(newH24)
|
||||||
|
emit(newH24, minuteRef.current)
|
||||||
|
} else {
|
||||||
|
const m = Math.round(angle / 6) % 60
|
||||||
|
setMinute(m)
|
||||||
|
emit(hour24Ref.current, m)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[emit]
|
||||||
|
)
|
||||||
|
|
||||||
|
const getSVGAngle = (clientX: number, clientY: number): number => {
|
||||||
|
if (!svgRef.current) return 0
|
||||||
|
const rect = svgRef.current.getBoundingClientRect()
|
||||||
|
const scale = rect.width / SIZE
|
||||||
|
const x = clientX - rect.left - CENTER * scale
|
||||||
|
const y = clientY - rect.top - CENTER * scale
|
||||||
|
return ((Math.atan2(y, x) * 180) / Math.PI + 90 + 360) % 360
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mouse handlers (mouse events don't need passive:false)
|
||||||
|
const handleMouseDown = (e: React.MouseEvent<SVGSVGElement>) => {
|
||||||
|
if (disabled) return
|
||||||
|
isDragging.current = true
|
||||||
|
applyAngle(getSVGAngle(e.clientX, e.clientY), modeRef.current)
|
||||||
|
}
|
||||||
|
const handleMouseMove = (e: React.MouseEvent<SVGSVGElement>) => {
|
||||||
|
if (!isDragging.current || disabled) return
|
||||||
|
applyAngle(getSVGAngle(e.clientX, e.clientY), modeRef.current)
|
||||||
|
}
|
||||||
|
const handleMouseUp = (e: React.MouseEvent<SVGSVGElement>) => {
|
||||||
|
if (!isDragging.current) return
|
||||||
|
isDragging.current = false
|
||||||
|
applyAngle(getSVGAngle(e.clientX, e.clientY), modeRef.current)
|
||||||
|
if (modeRef.current === 'hours') setTimeout(() => setMode('minutes'), 120)
|
||||||
|
}
|
||||||
|
const handleMouseLeave = () => { isDragging.current = false }
|
||||||
|
|
||||||
|
// Attach non-passive touch listeners imperatively to avoid the passive warning
|
||||||
|
useEffect(() => {
|
||||||
|
const svg = svgRef.current
|
||||||
|
if (!svg) return
|
||||||
|
|
||||||
|
const onTouchStart = (e: TouchEvent) => {
|
||||||
|
if (disabled) return
|
||||||
|
e.preventDefault()
|
||||||
|
isDragging.current = true
|
||||||
|
const t = e.touches[0]
|
||||||
|
applyAngle(getSVGAngle(t.clientX, t.clientY), modeRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onTouchMove = (e: TouchEvent) => {
|
||||||
|
if (!isDragging.current || disabled) return
|
||||||
|
e.preventDefault()
|
||||||
|
const t = e.touches[0]
|
||||||
|
applyAngle(getSVGAngle(t.clientX, t.clientY), modeRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onTouchEnd = (e: TouchEvent) => {
|
||||||
|
if (!isDragging.current) return
|
||||||
|
e.preventDefault()
|
||||||
|
isDragging.current = false
|
||||||
|
const t = e.changedTouches[0]
|
||||||
|
applyAngle(getSVGAngle(t.clientX, t.clientY), modeRef.current)
|
||||||
|
if (modeRef.current === 'hours') setTimeout(() => setMode('minutes'), 120)
|
||||||
|
}
|
||||||
|
|
||||||
|
svg.addEventListener('touchstart', onTouchStart, { passive: false })
|
||||||
|
svg.addEventListener('touchmove', onTouchMove, { passive: false })
|
||||||
|
svg.addEventListener('touchend', onTouchEnd, { passive: false })
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
svg.removeEventListener('touchstart', onTouchStart)
|
||||||
|
svg.removeEventListener('touchmove', onTouchMove)
|
||||||
|
svg.removeEventListener('touchend', onTouchEnd)
|
||||||
|
}
|
||||||
|
}, [applyAngle, disabled])
|
||||||
|
|
||||||
|
const handAngle = mode === 'hours' ? (hour12 / 12) * 360 : (minute / 60) * 360
|
||||||
|
const handTip = polarToXY(handAngle, HAND_RADIUS)
|
||||||
|
const displayH = hour12.toString()
|
||||||
|
const displayM = minute.toString().padStart(2, '0')
|
||||||
|
const selectedNum = mode === 'hours' ? hour12 : minute
|
||||||
|
|
||||||
|
const hourPositions = Array.from({ length: 12 }, (_, i) => {
|
||||||
|
const h = i + 1
|
||||||
|
return { h, ...polarToXY((h / 12) * 360, NUM_RADIUS) }
|
||||||
|
})
|
||||||
|
|
||||||
|
const minutePositions = Array.from({ length: 12 }, (_, i) => {
|
||||||
|
const m = i * 5
|
||||||
|
return { m, ...polarToXY((m / 60) * 360, NUM_RADIUS) }
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="clock-picker">
|
||||||
|
{/* Time display */}
|
||||||
|
<div className="clock-picker__display">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`clock-picker__seg${mode === 'hours' ? ' clock-picker__seg--active' : ''}`}
|
||||||
|
onClick={() => !disabled && setMode('hours')}
|
||||||
|
>
|
||||||
|
{displayH}
|
||||||
|
</button>
|
||||||
|
<span className="clock-picker__colon">:</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`clock-picker__seg${mode === 'minutes' ? ' clock-picker__seg--active' : ''}`}
|
||||||
|
onClick={() => !disabled && setMode('minutes')}
|
||||||
|
>
|
||||||
|
{displayM}
|
||||||
|
</button>
|
||||||
|
<div className="clock-picker__ampm">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`clock-picker__ampm-btn${!isPM ? ' clock-picker__ampm-btn--active' : ''}`}
|
||||||
|
onClick={() => handleAmPm(false)}
|
||||||
|
disabled={disabled}
|
||||||
|
>AM</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`clock-picker__ampm-btn${isPM ? ' clock-picker__ampm-btn--active' : ''}`}
|
||||||
|
onClick={() => handleAmPm(true)}
|
||||||
|
disabled={disabled}
|
||||||
|
>PM</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Clock face */}
|
||||||
|
<svg
|
||||||
|
ref={svgRef}
|
||||||
|
viewBox={`0 0 ${SIZE} ${SIZE}`}
|
||||||
|
className="clock-picker__face"
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseUp={handleMouseUp}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
style={{ cursor: disabled ? 'default' : 'pointer', touchAction: 'none', userSelect: 'none' }}
|
||||||
|
>
|
||||||
|
<circle cx={CENTER} cy={CENTER} r={CLOCK_RADIUS} className="clock-picker__bg" />
|
||||||
|
|
||||||
|
{/* Shaded sector */}
|
||||||
|
{(() => {
|
||||||
|
const start = polarToXY(0, HAND_RADIUS)
|
||||||
|
const end = polarToXY(handAngle, HAND_RADIUS)
|
||||||
|
const large = handAngle > 180 ? 1 : 0
|
||||||
|
return (
|
||||||
|
<path
|
||||||
|
d={`M ${CENTER} ${CENTER} L ${start.x} ${start.y} A ${HAND_RADIUS} ${HAND_RADIUS} 0 ${large} 1 ${end.x} ${end.y} Z`}
|
||||||
|
className="clock-picker__sector"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
|
<line x1={CENTER} y1={CENTER} x2={handTip.x} y2={handTip.y} className="clock-picker__hand" />
|
||||||
|
<circle cx={CENTER} cy={CENTER} r={4} className="clock-picker__center-dot" />
|
||||||
|
<circle cx={handTip.x} cy={handTip.y} r={TIP_RADIUS} className="clock-picker__hand-tip" />
|
||||||
|
|
||||||
|
{mode === 'hours' && hourPositions.map(({ h, x, y }) => (
|
||||||
|
<text key={h} x={x} y={y} textAnchor="middle" dominantBaseline="central"
|
||||||
|
className={`clock-picker__num${h === selectedNum ? ' clock-picker__num--selected' : ''}`}
|
||||||
|
>{h}</text>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{mode === 'minutes' && minutePositions.map(({ m, x, y }) => (
|
||||||
|
<text key={m} x={x} y={y} textAnchor="middle" dominantBaseline="central"
|
||||||
|
className={`clock-picker__num${m === selectedNum ? ' clock-picker__num--selected' : ''}`}
|
||||||
|
>{m.toString().padStart(2, '0')}</text>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{mode === 'minutes' && Array.from({ length: 60 }, (_, i) => {
|
||||||
|
if (i % 5 === 0) return null
|
||||||
|
const angle = (i / 60) * 360
|
||||||
|
const inner = polarToXY(angle, CLOCK_RADIUS - 10)
|
||||||
|
const outer = polarToXY(angle, CLOCK_RADIUS - 4)
|
||||||
|
return <line key={i} x1={inner.x} y1={inner.y} x2={outer.x} y2={outer.y} className="clock-picker__tick" />
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Mode pills */}
|
||||||
|
<div className="clock-picker__modes">
|
||||||
|
<button type="button"
|
||||||
|
className={`clock-picker__mode-btn${mode === 'hours' ? ' clock-picker__mode-btn--active' : ''}`}
|
||||||
|
onClick={() => !disabled && setMode('hours')}
|
||||||
|
>Hours</button>
|
||||||
|
<button type="button"
|
||||||
|
className={`clock-picker__mode-btn${mode === 'minutes' ? ' clock-picker__mode-btn--active' : ''}`}
|
||||||
|
onClick={() => !disabled && setMode('minutes')}
|
||||||
|
>Minutes</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
43
src/hooks/reminderApi.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/** API calls specific to FCM token registration and reminder settings. */
|
||||||
|
|
||||||
|
const BASE = import.meta.env.VITE_API_URL || 'http://localhost:8001/api'
|
||||||
|
|
||||||
|
async function post(url: string, body: unknown, token: string) {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}))
|
||||||
|
throw new Error(err.detail || res.statusText)
|
||||||
|
}
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function put(url: string, body: unknown, token: string) {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}))
|
||||||
|
throw new Error(err.detail || res.statusText)
|
||||||
|
}
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveFcmToken(userId: string, fcmToken: string, authToken: string) {
|
||||||
|
return post(`${BASE}/notifications/fcm-token`, { userId, fcmToken }, authToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveReminderSettings(
|
||||||
|
userId: string,
|
||||||
|
settings: { time?: string; enabled: boolean; timezone?: string },
|
||||||
|
authToken: string
|
||||||
|
) {
|
||||||
|
return put(`${BASE}/notifications/reminder/${userId}`, settings, authToken)
|
||||||
|
}
|
||||||
43
src/hooks/usePageMeta.ts
Normal file
43
src/hooks/usePageMeta.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
|
interface PageMeta {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
canonical: string
|
||||||
|
ogTitle?: string
|
||||||
|
ogDescription?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePageMeta({ title, description, canonical, ogTitle, ogDescription }: PageMeta) {
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = title
|
||||||
|
|
||||||
|
setMeta('name', 'description', description)
|
||||||
|
setMeta('property', 'og:title', ogTitle ?? title)
|
||||||
|
setMeta('property', 'og:description', ogDescription ?? description)
|
||||||
|
setMeta('property', 'og:url', canonical)
|
||||||
|
setMeta('name', 'twitter:title', ogTitle ?? title)
|
||||||
|
setMeta('name', 'twitter:description', ogDescription ?? description)
|
||||||
|
setLink('canonical', canonical)
|
||||||
|
}, [title, description, canonical, ogTitle, ogDescription])
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMeta(attr: 'name' | 'property', key: string, value: string) {
|
||||||
|
let el = document.querySelector<HTMLMetaElement>(`meta[${attr}="${key}"]`)
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement('meta')
|
||||||
|
el.setAttribute(attr, key)
|
||||||
|
document.head.appendChild(el)
|
||||||
|
}
|
||||||
|
el.setAttribute('content', value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLink(rel: string, href: string) {
|
||||||
|
let el = document.querySelector<HTMLLinkElement>(`link[rel="${rel}"]`)
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement('link')
|
||||||
|
el.setAttribute('rel', rel)
|
||||||
|
document.head.appendChild(el)
|
||||||
|
}
|
||||||
|
el.setAttribute('href', href)
|
||||||
|
}
|
||||||
114
src/hooks/useReminder.ts
Normal file
114
src/hooks/useReminder.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}`, {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
106
src/pages/TermsOfServicePage.tsx
Normal file
106
src/pages/TermsOfServicePage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user