diff --git a/TODO.md b/TODO.md deleted file mode 100644 index ff299dc..0000000 --- a/TODO.md +++ /dev/null @@ -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 diff --git a/backend/__pycache__/models.cpython-312.pyc b/backend/__pycache__/models.cpython-312.pyc index 1b1cfe7..105e999 100644 Binary files a/backend/__pycache__/models.cpython-312.pyc and b/backend/__pycache__/models.cpython-312.pyc differ diff --git a/backend/models.py b/backend/models.py index 02529e2..0e81f34 100644 --- a/backend/models.py +++ b/backend/models.py @@ -39,6 +39,8 @@ class UserUpdate(BaseModel): photoURL: Optional[str] = None theme: Optional[str] = None tutorial: Optional[bool] = None + backgroundImage: Optional[str] = None + backgroundImages: Optional[List[str]] = None class Config: json_schema_extra = { diff --git a/backend/routers/users.py b/backend/routers/users.py index c6a6d3b..dd88015 100644 --- a/backend/routers/users.py +++ b/backend/routers/users.py @@ -52,6 +52,8 @@ async def register_user(user_data: UserCreate): "displayName": user["displayName"], "photoURL": user.get("photoURL"), "theme": user.get("theme", "light"), + "backgroundImage": user.get("backgroundImage"), + "backgroundImages": user.get("backgroundImages", []), "createdAt": user["createdAt"].isoformat(), "updatedAt": user["updatedAt"].isoformat(), "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"), "photoURL": user.get("photoURL"), "theme": user.get("theme", "light"), + "backgroundImage": user.get("backgroundImage"), + "backgroundImages": user.get("backgroundImages", []), "tutorial": user.get("tutorial"), "createdAt": user["createdAt"].isoformat(), "updatedAt": user["updatedAt"].isoformat() @@ -111,6 +115,8 @@ async def get_user_by_id(user_id: str): "displayName": user.get("displayName"), "photoURL": user.get("photoURL"), "theme": user.get("theme", "light"), + "backgroundImage": user.get("backgroundImage"), + "backgroundImages": user.get("backgroundImages", []), "createdAt": user["createdAt"].isoformat(), "updatedAt": user["updatedAt"].isoformat() } @@ -152,6 +158,8 @@ async def update_user(user_id: str, user_data: UserUpdate): "displayName": user.get("displayName"), "photoURL": user.get("photoURL"), "theme": user.get("theme", "light"), + "backgroundImage": user.get("backgroundImage"), + "backgroundImages": user.get("backgroundImages", []), "tutorial": user.get("tutorial"), "createdAt": user["createdAt"].isoformat(), "updatedAt": user["updatedAt"].isoformat(), diff --git a/src/App.css b/src/App.css index 2e4eb19..df191d0 100644 --- a/src/App.css +++ b/src/App.css @@ -3095,3 +3095,319 @@ .static-page__footer span { 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)); + max-height: 85vh; + overflow-y: auto; + box-shadow: 0 8px 40px rgba(0, 0, 0, 0.18); +} + +.bg-gallery { + display: flex; + gap: 0.6rem; + overflow-x: auto; + padding: 0.25rem 0 0.75rem; + scrollbar-width: none; + -ms-overflow-style: none; +} + +.bg-gallery::-webkit-scrollbar { display: none; } + +/* Shared thumb/swatch styles */ +.bg-gallery-swatch, +.bg-gallery-thumb, +.bg-gallery-add { + flex-shrink: 0; + width: 84px; + height: 58px; + border-radius: 10px; + border: 2.5px solid transparent; + overflow: visible; + cursor: pointer; + position: relative; + padding: 0; + transition: border-color 0.15s, transform 0.12s; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: none; +} + +.bg-gallery-swatch, +.bg-gallery-thumb { + overflow: hidden; +} + +.bg-gallery-swatch:hover, +.bg-gallery-thumb:hover, +.bg-gallery-add:hover { + transform: scale(1.04); +} + +.bg-gallery-item--active { + border-color: var(--color-primary, #22c55e) !important; +} + +/* Default color swatch */ +.bg-gallery-swatch-fill { + width: 100%; + height: 100%; + background: #eef6ee; + border-radius: 7px; +} + +/* Thumbnail image */ +.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; +} + +/* Label below swatch/add button */ +.bg-gallery-label { + position: absolute; + bottom: -18px; + left: 50%; + transform: translateX(-50%); + font-size: 0.65rem; + color: var(--color-text-muted, #6b7280); + white-space: nowrap; +} + +/* "+" add button */ +.bg-gallery-add { + border: 2px dashed var(--color-border, #d4e8d4); + background: var(--color-accent-light, #dcfce7); + color: var(--color-text-muted, #6b7280); + gap: 0.1rem; + border-radius: 10px; +} + +.bg-gallery-add:hover { + border-color: var(--color-primary, #22c55e); + color: var(--color-primary, #22c55e); +} + +/* Close button */ +.bg-close-btn { + width: 100%; + margin-top: 1.5rem; + 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-gallery-swatch-fill { + background: #0f0f0f; +} + +[data-theme="dark"] .bg-gallery-add { + background: rgba(255, 255, 255, 0.05); + border-color: rgba(255, 255, 255, 0.12); + color: #9ca3af; +} + +[data-theme="dark"] .bg-gallery-add:hover { + 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); +} diff --git a/src/components/BgImageCropper.tsx b/src/components/BgImageCropper.tsx new file mode 100644 index 0000000..3b990be --- /dev/null +++ b/src/components/BgImageCropper.tsx @@ -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(null) + const imgRef = useRef(null) + + // Keep crop box in both a ref (for event handlers, avoids stale closure) and state (for rendering) + const cropRef = useRef(null) + const [cropBox, setCropBox] = useState(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.72)) + }, [aspectRatio, onCrop]) + + return ( +
+
+ + Crop Background + +
+ +
+ + + {cropBox && ( + <> + {/* Darkened area outside crop box via box-shadow */} +
+ + {/* Moveable crop box */} +
onPointerDown(e, 'move')} + > + {/* Rule-of-thirds grid */} +
+ + {/* Resize handles */} +
onPointerDown(e, 'tl')} /> +
onPointerDown(e, 'tr')} /> +
onPointerDown(e, 'bl')} /> +
onPointerDown(e, 'br')} /> +
+ + )} +
+ +

Drag to move · Drag corners to resize

+
+ ) +} diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 0d94513..520be32 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -38,6 +38,8 @@ type MongoUser = { photoURL?: string theme?: string tutorial?: boolean + backgroundImage?: string | null + backgroundImages?: string[] } type AuthContextValue = { @@ -62,6 +64,21 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [loading, setLoading] = useState(true) const [authError, setAuthError] = useState(null) + // Apply custom background image whenever mongoUser changes + useEffect(() => { + const bg = mongoUser?.backgroundImage + if (bg) { + document.body.style.backgroundImage = `url(${bg})` + document.body.style.backgroundSize = 'cover' + document.body.style.backgroundPosition = 'center' + document.body.style.backgroundAttachment = 'fixed' + document.body.classList.add('gj-has-bg') + } else { + document.body.style.backgroundImage = '' + document.body.classList.remove('gj-has-bg') + } + }, [mongoUser?.backgroundImage]) + // Initialize encryption keys on login async function initializeEncryption(authUser: User) { try { diff --git a/src/lib/api.ts b/src/lib/api.ts index c31e139..e055af8 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -70,7 +70,7 @@ export async function getUserByEmail(email: string, token: string) { export async function updateUserProfile( userId: string, - updates: { displayName?: string; photoURL?: string; theme?: string; tutorial?: boolean }, + updates: { displayName?: string; photoURL?: string; theme?: string; tutorial?: boolean; backgroundImage?: string | null; backgroundImages?: string[] }, token: string ) { return apiCall(`/users/${userId}`, { diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 5c513c2..4ee8730 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback, useRef } from 'react' import { useAuth } from '../contexts/AuthContext' import { deleteUser as deleteUserApi, updateUserProfile } from '../lib/api' +import { BgImageCropper } from '../components/BgImageCropper' import { clearDeviceKey, clearEncryptedSecretKey } from '../lib/crypto' import { useNavigate } from 'react-router-dom' import BottomNav from '../components/BottomNav' @@ -13,6 +14,7 @@ import { } from '../hooks/useReminder' const MAX_PHOTO_SIZE = 200 // px — resize to 200x200 +const MAX_BG_HISTORY = 3 function resizeImage(file: File): Promise { return new Promise((resolve, reject) => { @@ -73,6 +75,16 @@ export default function SettingsPage() { const [editPhotoPreview, setEditPhotoPreview] = useState(null) const [saving, setSaving] = useState(false) + // Background image state + const bgFileInputRef = useRef(null) + const [showBgModal, setShowBgModal] = useState(false) + const [cropperSrc, setCropperSrc] = useState(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 + // Continue onboarding tour if navigated here from the history page tour useEffect(() => { if (hasPendingTourStep() === 'settings') { @@ -136,6 +148,52 @@ export default function SettingsPage() { } } + async function bgUpdate(updates: Parameters[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 handleBgFileSelect = (e: React.ChangeEvent) => { + 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 const applyTheme = useCallback((t: 'light' | 'dark') => { document.documentElement.setAttribute('data-theme', t) @@ -443,6 +501,27 @@ export default function SettingsPage() {
*/} + + +
+
@@ -718,6 +797,110 @@ export default function SettingsPage() {
)} + {/* Background Image Gallery Modal */} + {showBgModal && ( +
!bgApplying && setShowBgModal(false)}> +
e.stopPropagation()}> +

Background

+

+ Tap to apply · + to upload new +

+ + {/* Hidden file input */} + + + {/* Gallery row */} +
+ {/* Default color swatch — always first */} + + + {/* History thumbnails */} + {bgImages.map((img, i) => ( + + ))} + + {/* Add new */} + +
+ + {bgApplying && ( +

+ Saving… +

+ )} + + +
+
+ )} + + {/* Fullscreen image cropper */} + {cropperSrc && ( + + )} + {/* Daily Reminder Modal */} {showReminderModal && (
!reminderSaving && setShowReminderModal(false)}>