added image upload feature

This commit is contained in:
2026-03-16 12:10:55 +05:30
parent ef52695bd9
commit e841860bd4
4 changed files with 324 additions and 11 deletions

View File

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

View File

@@ -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<void>
signOut: () => Promise<void>
refreshMongoUser: () => Promise<void>
}
const AuthContext = createContext<AuthContextValue | null>(null)
@@ -43,6 +53,7 @@ const AuthContext = createContext<AuthContextValue | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [userId, setUserId] = useState<string | null>(null)
const [mongoUser, setMongoUser] = useState<MongoUser | null>(null)
const [secretKey, setSecretKey] = useState<Uint8Array | null>(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 (

View File

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

View File

@@ -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<string> {
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<HTMLInputElement>(null)
// Edit profile modal state
const [showEditModal, setShowEditModal] = useState(false)
const [editName, setEditName] = useState('')
const [editPhotoPreview, setEditPhotoPreview] = useState<string | null>(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<HTMLInputElement>) => {
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 */}
<div className="settings-profile">
<div className="settings-avatar">
<div className="settings-avatar-placeholder" style={{ fontSize: '1.75rem', background: 'linear-gradient(135deg, #86efac 0%, #22c55e 100%)' }}>
🍀
</div>
{photoURL ? (
<img src={photoURL} alt={displayName} className="settings-avatar-img" />
) : (
<div className="settings-avatar-placeholder" style={{ fontSize: '1.75rem', background: 'linear-gradient(135deg, #86efac 0%, #22c55e 100%)' }}>
{displayName.charAt(0).toUpperCase()}
</div>
)}
</div>
<div className="settings-profile-info">
<h2 className="settings-profile-name">{displayName}</h2>
{/* <span className="settings-profile-badge">PRO MEMBER</span> */}
</div>
<button type="button" className="settings-edit-btn" onClick={openEditModal} title="Edit profile">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
</div>
{/* Privacy & Security */}
@@ -339,6 +428,80 @@ export default function SettingsPage() {
</div>
)}
{/* Edit Profile Modal */}
{showEditModal && (
<div className="confirm-modal-overlay" onClick={() => !saving && setShowEditModal(false)}>
<div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
<h3 className="edit-modal-title">Edit Profile</h3>
<div className="edit-modal-avatar" onClick={() => fileInputRef.current?.click()}>
{editPhotoPreview ? (
<img src={editPhotoPreview} alt="Preview" className="edit-modal-avatar-img" />
) : (
<div className="edit-modal-avatar-placeholder">
{editName.charAt(0).toUpperCase() || 'U'}
</div>
)}
<div className="edit-modal-avatar-overlay">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
<circle cx="12" cy="13" r="4" />
</svg>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={handlePhotoSelect}
/>
</div>
{editPhotoPreview && (
<button
type="button"
className="edit-modal-remove-photo"
onClick={() => setEditPhotoPreview(null)}
>
Remove photo
</button>
)}
<label className="confirm-modal-label" style={{ marginTop: '1rem' }}>Display Name</label>
<input
type="text"
className="confirm-modal-input"
value={editName}
onChange={(e) => 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')}
/>
<div className="confirm-modal-actions" style={{ marginTop: '1rem' }}>
<button
type="button"
className="confirm-modal-cancel"
onClick={() => setShowEditModal(false)}
disabled={saving}
>
Cancel
</button>
<button
type="button"
className="edit-modal-save"
onClick={handleSaveProfile}
disabled={saving || !editName.trim()}
>
{saving ? 'Saving...' : 'Save'}
</button>
</div>
</div>
</div>
)}
<BottomNav />
</div>
)