Files
grateful-journal/src/pages/SettingsPage.tsx
2026-04-14 11:10:44 +05:30

987 lines
41 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'
import { useOnboardingTour, hasPendingTourStep, clearPendingTourStep } from '../hooks/useOnboardingTour'
import { PageLoader } from '../components/PageLoader'
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_BG_HISTORY = 3
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, 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'>(() => {
return (localStorage.getItem('gj-theme') as 'light' | 'dark') || 'light'
})
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
// Clear Data confirmation modal state
const [showClearModal, setShowClearModal] = useState(false)
const [confirmEmail, setConfirmEmail] = useState('')
const [deleting, setDeleting] = useState(false)
const { continueTourOnSettings } = useOnboardingTour()
const navigate = useNavigate()
const fileInputRef = useRef<HTMLInputElement>(null)
const { canInstall, isIOS, triggerInstall } = usePWAInstall()
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
const [showEditModal, setShowEditModal] = useState(false)
const [editName, setEditName] = useState('')
const [editPhotoPreview, setEditPhotoPreview] = useState<string | null>(null)
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
useEffect(() => {
if (hasPendingTourStep() === 'settings') {
clearPendingTourStep()
continueTourOnSettings()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const handleSeeTutorial = () => {
localStorage.setItem('gj-force-tour', 'true')
navigate('/write')
}
const displayName = mongoUser?.displayName || user?.displayName || 'User'
// Use custom uploaded photo (base64) if set, otherwise always use Firebase's fresh Google URL
const mongoPhoto = mongoUser && 'photoURL' in mongoUser ? mongoUser.photoURL : null
const photoURL = (mongoPhoto?.startsWith('data:')) ? mongoPhoto : (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)
}
}
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
const applyTheme = useCallback((t: 'light' | 'dark') => {
document.documentElement.setAttribute('data-theme', t)
localStorage.setItem('gj-theme', t)
}, [])
// Apply saved theme on mount
useEffect(() => {
applyTheme(theme)
}, [theme, applyTheme])
const handleThemeChange = (newTheme: 'light' | 'dark') => {
setTheme(newTheme)
applyTheme(newTheme)
setMessage({ type: 'success', text: `Switched to ${newTheme === 'light' ? 'Light' : 'Dark'} theme` })
setTimeout(() => setMessage(null), 2000)
}
const handleClearData = () => {
setConfirmEmail('')
setShowClearModal(true)
}
const handleConfirmDelete = async () => {
if (!user || !userId) return
const userEmail = user.email || ''
if (confirmEmail.trim().toLowerCase() !== userEmail.toLowerCase()) {
setMessage({ type: 'error', text: 'Email does not match. Please try again.' })
return
}
setDeleting(true)
setMessage(null)
try {
const token = await user.getIdToken()
// Delete user and all entries from backend
await deleteUserApi(userId, token)
// Clear all local crypto data
clearDeviceKey()
await clearEncryptedSecretKey()
localStorage.removeItem('gj-kdf-salt')
localStorage.removeItem('gj-theme')
setShowClearModal(false)
// Sign out (clears auth state)
await signOut()
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to delete account'
setMessage({ type: 'error', text: errorMessage })
setDeleting(false)
}
}
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 () => {
try {
await signOut()
} catch (error) {
console.error('Error signing out:', error)
}
}
if (loading) {
return <PageLoader />
}
return (
<div className="settings-page">
<header className="settings-header">
<div className="settings-header-text">
<h1>Settings</h1>
<p className="settings-subtitle">Manage your privacy and preferences.</p>
</div>
</header>
<main className="settings-container">
{/* Profile Section */}
<div id="tour-edit-profile" className="settings-profile">
<div className="settings-avatar" onClick={openEditModal} style={{ cursor: 'pointer' }}>
{photoURL ? (
<img src={photoURL} alt={displayName} className="settings-avatar-img"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
) : (
<div className="settings-avatar-placeholder" style={{ fontSize: '1.75rem' }}>
{displayName.charAt(0).toUpperCase()}
</div>
)}
</div>
<div className="settings-profile-info">
<h2 className="settings-profile-name">{displayName}</h2>
</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 */}
<section className="settings-section">
<h3 className="settings-section-title">PRIVACY & SECURITY</h3>
<div className="settings-card">
{/* Passcode Lock — disabled for now, toggle is non-functional */}
<div className="settings-item" style={{ opacity: 0.5 }}>
<div className="settings-item-icon settings-item-icon-green">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
</div>
<div className="settings-item-content">
<h4 className="settings-item-title">Passcode Lock</h4>
<p className="settings-item-subtitle">Coming soon</p>
</div>
<label className="settings-toggle">
<input
type="checkbox"
checked={false}
disabled
readOnly
/>
<span className="settings-toggle-slider" style={{ cursor: 'not-allowed' }}></span>
</label>
</div>
{/* Face ID — commented out for future use */}
{/*
<div className="settings-divider"></div>
<div className="settings-item">
<div className="settings-item-icon settings-item-icon-gray">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 12C2 6.5 6.5 2 12 2a10 10 0 0 1 8 4"></path>
<path d="M5 19.5C5.5 18 6 15 6 12c0-1.73.37-3.36 1.03-4.83"></path>
<path d="M10 21c.2-2 .4-5 .4-6 0-1 .2-1.93.56-2.78"></path>
<path d="M14 21c.2-2 .4-5 .4-6 0-3-1-5-3.4-6.5"></path>
<path d="M18.5 21C18 18.5 18 17 18 12c0-1-.07-2-.2-3"></path>
<path d="M22 12a10 10 0 0 1-1.53 5.35"></path>
</svg>
</div>
<div className="settings-item-content">
<h4 className="settings-item-title">Face ID</h4>
<p className="settings-item-subtitle">Unlock with glance</p>
</div>
<label className="settings-toggle">
<input
type="checkbox"
checked={faceIdEnabled}
onChange={(e) => setFaceIdEnabled(e.target.checked)}
/>
<span className="settings-toggle-slider"></span>
</label>
</div>
*/}
</div>
</section>
{/* App */}
<section className="settings-section">
<h3 className="settings-section-title">APP</h3>
<div className="settings-card">
<button
type="button"
className="settings-item settings-item-button"
onClick={() => {
if (isIOS) {
setInstallModal('ios')
} else if (canInstall) {
triggerInstall()
} else {
setInstallModal('chrome')
}
}}
>
<div className="settings-item-icon settings-item-icon-purple">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="5" y="2" width="14" height="20" rx="2" ry="2" />
<line x1="12" y1="18" x2="12.01" y2="18" />
</svg>
</div>
<div className="settings-item-content">
<h4 className="settings-item-title">Add to Home Screen</h4>
<p className="settings-item-subtitle">Open as an app, no browser bar</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>
{/* 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>
</section>
{/* Data & Look */}
<section className="settings-section">
<h3 className="settings-section-title">DATA & LOOK</h3>
<div className="settings-card">
{/* Export Journal — commented out for future use */}
{/*
<button type="button" className="settings-item settings-item-button">
<div className="settings-item-icon settings-item-icon-orange">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
</div>
<div className="settings-item-content">
<h4 className="settings-item-title">Export Journal</h4>
<p className="settings-item-subtitle">PDF or Plain Text</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"></polyline>
</svg>
</button>
<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 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">
<circle cx="13.5" cy="6.5" r=".5"></circle>
<circle cx="17.5" cy="10.5" r=".5"></circle>
<circle cx="8.5" cy="7.5" r=".5"></circle>
<circle cx="6.5" cy="12.5" r=".5"></circle>
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z"></path>
</svg>
</div>
<div className="settings-item-content">
<h4 className="settings-item-title">Theme</h4>
<p className="settings-item-subtitle">Currently: {theme === 'light' ? 'Light' : 'Dark'}</p>
</div>
<div className="settings-theme-colors">
<button
type="button"
onClick={() => handleThemeChange('light')}
className={`settings-theme-dot settings-theme-dot-beige${theme === 'light' ? ' settings-theme-dot-active' : ''}`}
title="Light theme"
></button>
<button
type="button"
onClick={() => handleThemeChange('dark')}
className={`settings-theme-dot settings-theme-dot-dark${theme === 'dark' ? ' settings-theme-dot-active' : ''}`}
title="Dark theme"
></button>
</div>
</div>
</div>
</section>
{message && (
<div className={`alert-msg alert-msg--${message.type}`} style={{ marginBottom: '1rem' }}>
{message.text}
</div>
)}
{/* See Tutorial */}
<button type="button" className="settings-tutorial-btn" onClick={handleSeeTutorial}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
<span>See Tutorial</span>
</button>
{/* Clear Data */}
<button type="button" className="settings-clear-btn" onClick={handleClearData}>
<span>Clear All Data</span>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>
{/* Sign Out */}
<button type="button" className="settings-signout-btn" onClick={handleSignOut}>
Sign Out
</button>
{/* Version */}
{/* <p className="settings-version">VERSION 1.0.2</p> */}
<p className="settings-enc"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
End-to-end Encrypted</p>
</main>
{/* Clear Data Confirmation Modal */}
{showClearModal && (
<div className="confirm-modal-overlay" onClick={() => !deleting && setShowClearModal(false)}>
<div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
<div className="confirm-modal-icon"></div>
<h3 className="confirm-modal-title">Delete All Data?</h3>
<p className="confirm-modal-desc">
This will permanently delete your account, all journal entries, and local encryption keys. This action <strong>cannot be undone</strong>.
</p>
<p className="confirm-modal-label">
Type your email to confirm:
</p>
<input
type="email"
className="confirm-modal-input"
placeholder={user?.email || 'your@email.com'}
value={confirmEmail}
onChange={(e) => setConfirmEmail(e.target.value)}
disabled={deleting}
autoFocus
/>
<div className="confirm-modal-actions">
<button
type="button"
className="confirm-modal-cancel"
onClick={() => setShowClearModal(false)}
disabled={deleting}
>
Cancel
</button>
<button
type="button"
className="confirm-modal-delete"
onClick={handleConfirmDelete}
disabled={deleting || !confirmEmail.trim()}
>
{deleting ? 'Deleting…' : 'Delete Everything'}
</button>
</div>
</div>
</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>
<label className="edit-modal-avatar" style={{ cursor: 'pointer' }}>
<input
ref={fileInputRef}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={handlePhotoSelect}
/>
{editPhotoPreview ? (
<img src={editPhotoPreview} alt="Preview" className="edit-modal-avatar-img"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
/>
) : (
<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>
</label>
{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
onFocus={(e) => (e.target.style.borderColor = 'var(--color-primary)')}
onBlur={(e) => (e.target.style.borderColor = '')}
/>
<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>
)}
{/* Add to Home Screen instructions modal */}
{installModal && (
<div className="confirm-modal-overlay" onClick={() => setInstallModal(null)}>
<div className="confirm-modal ios-install-modal" onClick={(e) => e.stopPropagation()}>
<div className="ios-install-icon">🌱</div>
<h3 className="confirm-modal-title">Add to Home Screen</h3>
{installModal === 'ios' ? (
<>
<p className="ios-install-subtitle">Follow these steps in Safari:</p>
<ol className="ios-install-steps">
<li>
<span className="ios-install-step-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" />
<polyline points="16 6 12 2 8 6" />
<line x1="12" y1="2" x2="12" y2="15" />
</svg>
</span>
Tap the <strong>Share</strong> button at the bottom of Safari
</li>
<li>
<span className="ios-install-step-icon">
<svg width="18" height="18" 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" />
<line x1="12" y1="8" x2="12" y2="16" />
<line x1="8" y1="12" x2="16" y2="12" />
</svg>
</span>
Scroll down and tap <strong>Add to Home Screen</strong>
</li>
<li>
<span className="ios-install-step-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
</span>
Tap <strong>Add</strong> to confirm
</li>
</ol>
</>
) : (
<>
<p className="ios-install-subtitle">Follow these steps in Chrome:</p>
<ol className="ios-install-steps">
<li>
<span className="ios-install-step-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="1" /><circle cx="19" cy="12" r="1" /><circle cx="5" cy="12" r="1" />
</svg>
</span>
Tap the <strong> menu</strong> in the top-right corner
</li>
<li>
<span className="ios-install-step-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="5" y="2" width="14" height="20" rx="2" ry="2" />
<line x1="12" y1="18" x2="12.01" y2="18" />
</svg>
</span>
Tap <strong>Add to Home screen</strong>
</li>
<li>
<span className="ios-install-step-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
</span>
Tap <strong>Add</strong> to confirm
</li>
</ol>
</>
)}
<button
type="button"
className="edit-modal-save"
style={{ width: '100%', marginTop: '1rem' }}
onClick={() => setInstallModal(null)}
>
Got it
</button>
</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 />
</div>
)
}