247 lines
7.9 KiB
TypeScript
247 lines
7.9 KiB
TypeScript
import { useAuth } from '../contexts/AuthContext'
|
|
import { Link, useNavigate } from 'react-router-dom'
|
|
import { useState, useRef, useEffect } from 'react'
|
|
import { createEntry, updateUserProfile } from '../lib/api'
|
|
import { encryptEntry } from '../lib/crypto'
|
|
import BottomNav from '../components/BottomNav'
|
|
import WelcomeModal from '../components/WelcomeModal'
|
|
import { SaveBookAnimation } from '../components/SaveBookAnimation'
|
|
import { useOnboardingTour } from '../hooks/useOnboardingTour'
|
|
import { PageLoader } from '../components/PageLoader'
|
|
|
|
const AFFIRMATIONS = [
|
|
'You showed up for yourself today 🌱',
|
|
'Another moment beautifully captured ✨',
|
|
'Gratitude logged. Keep growing 🌿',
|
|
'Small moments, big growth 🍃',
|
|
"You're building something beautiful 💚",
|
|
'One more grateful moment preserved 🌸',
|
|
'Your thoughts are safe and stored 🔒',
|
|
]
|
|
|
|
const SAVE_LEAVES = [
|
|
{ left: -80, dx: -30, rot: -25, delay: 0.0, emoji: '🌱' },
|
|
{ left: -45, dx: -12, rot: 15, delay: 0.08, emoji: '🌿' },
|
|
{ left: -15, dx: -22, rot: -10, delay: 0.04, emoji: '🌱' },
|
|
{ left: 8, dx: 18, rot: 20, delay: 0.12, emoji: '🍃' },
|
|
{ left: 38, dx: 27, rot: -18, delay: 0.06, emoji: '🌿' },
|
|
{ left: 68, dx: 38, rot: 12, delay: 0.18, emoji: '🌱' },
|
|
{ left: -62, dx: -33, rot: 28, delay: 0.22, emoji: '🍃' },
|
|
{ left: 25, dx: 15, rot: -22, delay: 0.28, emoji: '🌿' },
|
|
]
|
|
|
|
export default function HomePage() {
|
|
const { user, userId, mongoUser, secretKey, loading } = useAuth()
|
|
const navigate = useNavigate()
|
|
const [entry, setEntry] = useState('')
|
|
const [title, setTitle] = useState('')
|
|
const [phase, setPhase] = useState<'idle' | 'saving' | 'book' | 'celebrate'>('idle')
|
|
const [affirmation, setAffirmation] = useState('')
|
|
const [message, setMessage] = useState<{ type: 'error'; text: string } | null>(null)
|
|
const [showWelcome, setShowWelcome] = useState(false)
|
|
|
|
const titleInputRef = useRef<HTMLInputElement>(null)
|
|
const contentTextareaRef = useRef<HTMLTextAreaElement>(null)
|
|
|
|
const { startTour } = useOnboardingTour()
|
|
|
|
// Check if onboarding should be shown after login
|
|
useEffect(() => {
|
|
if (!loading && user && userId && mongoUser && !mongoUser.tutorial) {
|
|
setShowWelcome(true)
|
|
}
|
|
}, [loading, user, userId, mongoUser])
|
|
|
|
async function markTutorialDone() {
|
|
if (!user || !userId) return
|
|
const token = await user.getIdToken()
|
|
updateUserProfile(userId, { tutorial: true }, token).catch(console.error)
|
|
}
|
|
|
|
const handleStartTour = () => {
|
|
setShowWelcome(false)
|
|
markTutorialDone()
|
|
startTour()
|
|
}
|
|
|
|
const handleSkipTour = () => {
|
|
setShowWelcome(false)
|
|
markTutorialDone()
|
|
}
|
|
|
|
if (loading) {
|
|
return <PageLoader />
|
|
}
|
|
|
|
if (!user) {
|
|
return (
|
|
<div className="home-page" style={{ alignItems: 'center', justifyContent: 'center', gap: '1rem' }}>
|
|
<h1 style={{ fontFamily: '"Sniglet", system-ui', color: 'var(--color-text)' }}>Grateful Journal</h1>
|
|
<p style={{ color: 'var(--color-text-muted)' }}>Sign in to start your journal.</p>
|
|
<Link to="/login" className="home-login-link">Go to login</Link>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Format date: "THURSDAY, OCT 24"
|
|
const today = new Date()
|
|
const dateString = today
|
|
.toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' })
|
|
.toUpperCase()
|
|
|
|
const handleBookDone = () => {
|
|
setPhase('celebrate')
|
|
setTimeout(() => navigate('/history'), 2500)
|
|
}
|
|
|
|
const handleTitleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
if (e.key === 'Enter' && title.trim()) {
|
|
e.preventDefault()
|
|
contentTextareaRef.current?.focus()
|
|
}
|
|
}
|
|
|
|
const handleWrite = async () => {
|
|
if (!userId || !title.trim() || !entry.trim()) {
|
|
setMessage({ type: 'error', text: 'Please add a title and entry content' })
|
|
return
|
|
}
|
|
|
|
if (!secretKey) {
|
|
setMessage({ type: 'error', text: 'Encryption key not available. Please log in again.' })
|
|
return
|
|
}
|
|
|
|
setPhase('saving')
|
|
setMessage(null)
|
|
|
|
try {
|
|
const token = await user.getIdToken()
|
|
|
|
// Combine title and content for encryption
|
|
const contentToEncrypt = `${title.trim()}\n\n${entry.trim()}`
|
|
|
|
// Encrypt the entry with master key
|
|
const { ciphertext, nonce } = await encryptEntry(
|
|
contentToEncrypt,
|
|
secretKey
|
|
)
|
|
|
|
// Send encrypted data to backend
|
|
// Note: title and content are null for encrypted entries
|
|
await createEntry(
|
|
userId,
|
|
{
|
|
title: undefined,
|
|
content: undefined,
|
|
isPublic: false,
|
|
encryption: {
|
|
encrypted: true,
|
|
ciphertext,
|
|
nonce,
|
|
algorithm: 'XSalsa20-Poly1305',
|
|
},
|
|
},
|
|
token
|
|
)
|
|
|
|
setTitle('')
|
|
setEntry('')
|
|
setAffirmation(AFFIRMATIONS[Math.floor(Math.random() * AFFIRMATIONS.length)])
|
|
setPhase('book')
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : 'Failed to save entry'
|
|
setMessage({ type: 'error', text: errorMessage })
|
|
setPhase('idle')
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="home-page">
|
|
{showWelcome && (
|
|
<WelcomeModal onStart={handleStartTour} onSkip={handleSkipTour} />
|
|
)}
|
|
<main className="journal-container">
|
|
<div className="journal-card">
|
|
<div className="journal-date">{dateString}</div>
|
|
|
|
<h2 className="journal-prompt">What are you grateful for today?</h2>
|
|
|
|
<div className="journal-writing-area">
|
|
<input
|
|
type="text"
|
|
id="tour-title-input"
|
|
className="journal-title-input"
|
|
placeholder="Title your thoughts..."
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
onKeyDown={handleTitleKeyDown}
|
|
enterKeyHint="next"
|
|
ref={titleInputRef}
|
|
disabled={phase !== 'idle'}
|
|
/>
|
|
<textarea
|
|
id="tour-content-textarea"
|
|
className="journal-entry-textarea"
|
|
placeholder="Start writing your entry here..."
|
|
value={entry}
|
|
onChange={(e) => setEntry(e.target.value)}
|
|
enterKeyHint="enter"
|
|
ref={contentTextareaRef}
|
|
disabled={phase !== 'idle'}
|
|
/>
|
|
</div>
|
|
|
|
{message && (
|
|
<div className="alert-msg alert-msg--error" style={{ marginTop: '1rem' }}>
|
|
{message.text}
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ marginTop: '1.5rem', position: 'relative' }}>
|
|
{phase === 'celebrate' && (
|
|
<>
|
|
<div className="save-leaves" aria-hidden>
|
|
{SAVE_LEAVES.map((leaf, i) => (
|
|
<span
|
|
key={i}
|
|
className="save-leaf"
|
|
style={{
|
|
left: `calc(50% + ${leaf.left}px)`,
|
|
animationDelay: `${leaf.delay}s`,
|
|
'--leaf-dx': `${leaf.dx}px`,
|
|
'--leaf-rot': `${leaf.rot}deg`,
|
|
} as React.CSSProperties}
|
|
>
|
|
{leaf.emoji}
|
|
</span>
|
|
))}
|
|
</div>
|
|
<div className="save-inline-quote" role="status" aria-live="polite">
|
|
{affirmation}
|
|
</div>
|
|
</>
|
|
)}
|
|
{phase !== 'celebrate' && (
|
|
<button
|
|
id="tour-save-btn"
|
|
className="journal-write-btn"
|
|
onClick={handleWrite}
|
|
disabled={phase !== 'idle' || !title.trim() || !entry.trim()}
|
|
>
|
|
{phase === 'saving' ? 'Saving...' : 'Save Entry'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
</div>
|
|
</main>
|
|
|
|
{phase === 'book' && <SaveBookAnimation onDone={handleBookDone} />}
|
|
|
|
<BottomNav />
|
|
</div>
|
|
)
|
|
}
|
|
|