diff --git a/src/App.css b/src/App.css index 9b26faf..9019c72 100644 --- a/src/App.css +++ b/src/App.css @@ -754,6 +754,114 @@ text-transform: uppercase; } +.settings-edit-btn { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 10px; + border: none; + background: #f0fdf4; + color: #22c55e; + cursor: pointer; + flex-shrink: 0; + transition: background 0.15s; +} + +.settings-edit-btn:hover { + background: #dcfce7; +} + +.edit-modal-title { + font-size: 1.2rem; + font-weight: 700; + color: #1a1a1a; + margin: 0 0 1rem; + text-align: center; + font-family: "Sniglet", system-ui; +} + +.edit-modal-avatar { + position: relative; + width: 80px; + height: 80px; + margin: 0 auto 0.5rem; + cursor: pointer; + border-radius: 50%; + overflow: hidden; +} + +.edit-modal-avatar-img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 50%; +} + +.edit-modal-avatar-placeholder { + width: 100%; + height: 100%; + border-radius: 50%; + background: linear-gradient(135deg, #86efac 0%, #22c55e 100%); + display: flex; + align-items: center; + justify-content: center; + font-size: 2rem; + font-weight: 700; + color: #fff; + font-family: "Sniglet", system-ui; +} + +.edit-modal-avatar-overlay { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.35); + display: flex; + align-items: center; + justify-content: center; + color: #fff; + border-radius: 50%; +} + +.edit-modal-remove-photo { + display: block; + margin: 0 auto 0.5rem; + background: none; + border: none; + color: #ef4444; + font-size: 0.75rem; + cursor: pointer; + padding: 0.25rem 0.5rem; +} + +.edit-modal-remove-photo:hover { + text-decoration: underline; +} + +.edit-modal-save { + flex: 1; + padding: 0.65rem; + border: none; + border-radius: 12px; + font-size: 0.9rem; + font-weight: 600; + font-family: inherit; + cursor: pointer; + background: #22c55e; + color: #fff; + transition: background 0.15s; +} + +.edit-modal-save:hover:not(:disabled) { + background: #16a34a; +} + +.edit-modal-save:disabled { + opacity: 0.5; + cursor: not-allowed; +} + .settings-section { margin-bottom: 1rem; } @@ -1715,6 +1823,19 @@ color: #fff; } +[data-theme="dark"] .settings-edit-btn { + background: rgba(34, 197, 94, 0.15); + color: #4ade80; +} + +[data-theme="dark"] .settings-edit-btn:hover { + background: rgba(34, 197, 94, 0.25); +} + +[data-theme="dark"] .edit-modal-title { + color: #e8f5e8; +} + /* -- Settings divider -- */ [data-theme="dark"] .settings-divider { background: #2a2a2a; diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 074b2ce..e9cb473 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -29,13 +29,23 @@ import { getEncryptedSecretKey, } from '../lib/crypto' +type MongoUser = { + id: string + email: string + displayName?: string + photoURL?: string + theme?: string +} + type AuthContextValue = { user: User | null userId: string | null + mongoUser: MongoUser | null loading: boolean secretKey: Uint8Array | null signInWithGoogle: () => Promise signOut: () => Promise + refreshMongoUser: () => Promise } const AuthContext = createContext(null) @@ -43,6 +53,7 @@ const AuthContext = createContext(null) export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null) const [userId, setUserId] = useState(null) + const [mongoUser, setMongoUser] = useState(null) const [secretKey, setSecretKey] = useState(null) const [loading, setLoading] = useState(true) @@ -114,9 +125,10 @@ export function AuthProvider({ children }: { children: ReactNode }) { // Try to get existing user try { console.log('[Auth] Fetching user by email:', email) - const existingUser = await getUserByEmail(email, token) as { id: string } + const existingUser = await getUserByEmail(email, token) as MongoUser console.log('[Auth] Found existing user:', existingUser.id) setUserId(existingUser.id) + setMongoUser(existingUser) } catch (error) { console.warn('[Auth] User not found, registering...', error) // User doesn't exist, register them @@ -127,9 +139,10 @@ export function AuthProvider({ children }: { children: ReactNode }) { photoURL: authUser.photoURL || undefined, }, token - ) as { id: string } + ) as MongoUser console.log('[Auth] Registered new user:', newUser.id) setUserId(newUser.id) + setMongoUser(newUser) } } catch (error) { console.error('[Auth] Error syncing user with database:', error) @@ -148,6 +161,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { } } else { setUserId(null) + setMongoUser(null) setSecretKey(null) } setLoading(false) @@ -160,9 +174,22 @@ export function AuthProvider({ children }: { children: ReactNode }) { await signInWithPopup(auth, googleProvider) } + async function refreshMongoUser() { + if (!user) return + try { + const token = await user.getIdToken() + const email = user.email! + const updated = await getUserByEmail(email, token) as MongoUser + setMongoUser(updated) + } catch (error) { + console.error('[Auth] Error refreshing mongo user:', error) + } + } + async function signOut() { // Clear secret key from memory setSecretKey(null) + setMongoUser(null) // Reset onboarding so tour shows again on next login localStorage.removeItem('gj-onboarding-done') localStorage.removeItem('gj-tour-pending-step') @@ -175,10 +202,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { const value: AuthContextValue = { user, userId, + mongoUser, secretKey, loading, signInWithGoogle, signOut, + refreshMongoUser, } return ( diff --git a/src/lib/api.ts b/src/lib/api.ts index 66f6694..ec29501 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -73,7 +73,7 @@ export async function updateUserProfile( updates: { displayName?: string; photoURL?: string; theme?: string }, token: string ) { - return apiCall(`/api/users/update/${userId}`, { + return apiCall(`/api/users/${userId}`, { method: 'PUT', body: updates, token, diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 8d62d89..83c2541 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -1,13 +1,40 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' import { useAuth } from '../contexts/AuthContext' -import { deleteUser as deleteUserApi } from '../lib/api' +import { deleteUser as deleteUserApi, updateUserProfile } from '../lib/api' import { clearDeviceKey, clearEncryptedSecretKey } from '../lib/crypto' import { useNavigate } from 'react-router-dom' import BottomNav from '../components/BottomNav' import { useOnboardingTour, hasPendingTourStep, clearPendingTourStep } from '../hooks/useOnboardingTour' +const MAX_PHOTO_SIZE = 200 // px — resize to 200x200 + +function resizeImage(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => { + const img = new Image() + img.onload = () => { + const canvas = document.createElement('canvas') + canvas.width = MAX_PHOTO_SIZE + canvas.height = MAX_PHOTO_SIZE + const ctx = canvas.getContext('2d')! + // Crop to square from center + const side = Math.min(img.width, img.height) + const sx = (img.width - side) / 2 + const sy = (img.height - side) / 2 + ctx.drawImage(img, sx, sy, side, side, 0, 0, MAX_PHOTO_SIZE, MAX_PHOTO_SIZE) + resolve(canvas.toDataURL('image/jpeg', 0.8)) + } + img.onerror = reject + img.src = reader.result as string + } + reader.onerror = reject + reader.readAsDataURL(file) + }) +} + export default function SettingsPage() { - const { user, userId, signOut, loading } = useAuth() + const { user, userId, mongoUser, signOut, loading, refreshMongoUser } = useAuth() // const [passcodeEnabled, setPasscodeEnabled] = useState(false) // Passcode lock — disabled for now // const [faceIdEnabled, setFaceIdEnabled] = useState(false) // Face ID — disabled for now const [theme, setTheme] = useState<'light' | 'dark'>(() => { @@ -22,6 +49,13 @@ export default function SettingsPage() { const { continueTourOnSettings } = useOnboardingTour() const navigate = useNavigate() + const fileInputRef = useRef(null) + + // Edit profile modal state + const [showEditModal, setShowEditModal] = useState(false) + const [editName, setEditName] = useState('') + const [editPhotoPreview, setEditPhotoPreview] = useState(null) + const [saving, setSaving] = useState(false) // Continue onboarding tour if navigated here from the history page tour useEffect(() => { @@ -37,7 +71,53 @@ export default function SettingsPage() { navigate('/') } - const displayName = user?.displayName || 'User' + const displayName = mongoUser?.displayName || user?.displayName || 'User' + // Prefer mongo photo; only fall back to Google photo if mongo has no photo set + const photoURL = (mongoUser && 'photoURL' in mongoUser) ? (mongoUser.photoURL || null) : (user?.photoURL || null) + + const openEditModal = () => { + setEditName(displayName) + setEditPhotoPreview(photoURL) + setShowEditModal(true) + } + + const handlePhotoSelect = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + try { + const resized = await resizeImage(file) + setEditPhotoPreview(resized) + } catch { + setMessage({ type: 'error', text: 'Failed to process image' }) + } + } + + const handleSaveProfile = async () => { + if (!user || !userId) return + setSaving(true) + try { + const token = await user.getIdToken() + const updates: { displayName?: string; photoURL?: string } = {} + if (editName.trim() && editName.trim() !== displayName) { + updates.displayName = editName.trim() + } + if (editPhotoPreview !== photoURL) { + updates.photoURL = editPhotoPreview || '' + } + if (Object.keys(updates).length > 0) { + await updateUserProfile(userId, updates, token) + await refreshMongoUser() + setMessage({ type: 'success', text: 'Profile updated!' }) + setTimeout(() => setMessage(null), 2000) + } + setShowEditModal(false) + } catch (error) { + const msg = error instanceof Error ? error.message : 'Failed to update profile' + setMessage({ type: 'error', text: msg }) + } finally { + setSaving(false) + } + } // Apply theme to DOM const applyTheme = useCallback((t: 'light' | 'dark') => { @@ -127,14 +207,23 @@ export default function SettingsPage() { {/* Profile Section */}
-
- 🍀 -
+ {photoURL ? ( + {displayName} + ) : ( +
+ {displayName.charAt(0).toUpperCase()} +
+ )}

{displayName}

- {/* PRO MEMBER */}
+
{/* Privacy & Security */} @@ -339,6 +428,80 @@ export default function SettingsPage() { )} + {/* Edit Profile Modal */} + {showEditModal && ( +
!saving && setShowEditModal(false)}> +
e.stopPropagation()}> +

Edit Profile

+ +
fileInputRef.current?.click()}> + {editPhotoPreview ? ( + Preview + ) : ( +
+ {editName.charAt(0).toUpperCase() || 'U'} +
+ )} +
+ + + + +
+ +
+ {editPhotoPreview && ( + + )} + + + setEditName(e.target.value)} + disabled={saving} + maxLength={50} + autoFocus + style={{ borderColor: '#d1d5db' }} + onFocus={(e) => (e.target.style.borderColor = '#22c55e')} + onBlur={(e) => (e.target.style.borderColor = '#d1d5db')} + /> + +
+ + +
+
+
+ )} + )