987 lines
41 KiB
TypeScript
987 lines
41 KiB
TypeScript
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>
|
||
)
|
||
}
|